From 0eeed88a6dd323623093aed8c227624b238951e6 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 11:48:22 +0200 Subject: [PATCH 01/58] feat: send discovered servers during guard install --- src/agent_scan/cli.py | 12 + src/agent_scan/guard.py | 224 +++++++-- src/agent_scan/hooks/snyk-agent-guard.ps1 | 10 +- src/agent_scan/hooks/snyk-agent-guard.sh | 2 +- tests/e2e/test_guard_install.py | 27 +- tests/unit/test_guard.py | 564 +++++++++++++++++++++- 6 files changed, 782 insertions(+), 57 deletions(-) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index 1f7254c4..1ac071b5 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -942,6 +942,18 @@ 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", + "--control-identifier", + dest="machine_id", + type=str, + default=None, + metavar="ID", + help=( + "Non-anonymous identifier for this machine, sent as the X-User identifier on hook events " + "(accepts --control-identifier for symmetry with scan)" + ), + ) guard_install_parser.add_argument( "--test", action="store_true", diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 818bd4d8..285e8824 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -13,6 +13,7 @@ import sys from importlib import resources as importlib_resources from pathlib import Path +from typing import TYPE_CHECKING from urllib.parse import urlparse import rich @@ -26,6 +27,9 @@ ) from agent_scan.redact import redact_push_keys, redact_push_keys_in_data +if TYPE_CHECKING: + from agent_scan.models import ClientToInspect + IS_WINDOWS = sys.platform == "win32" # --------------------------------------------------------------------------- @@ -186,6 +190,7 @@ 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() clients = ALL_CLIENTS if client == "all" else [client] @@ -253,9 +258,10 @@ def _run_install(args) -> None: minted = not headless # True if we minted the key in this run installed_any = False + first_installed: tuple[str, Path] | None = None try: for c in clients: - _install_hooks( + dest_path = _install_hooks( c, _hook_client_name(c), push_key, @@ -266,7 +272,10 @@ def _run_install(args) -> None: minted, tenant_id, snyk_token, + machine_id, ) + if first_installed is None: + first_installed = (_hook_client_name(c), dest_path) installed_any = True except BaseException: if minted: @@ -280,6 +289,9 @@ def _run_install(args) -> None: _revoke_after_failure(url, tenant_id, snyk_token, push_key) raise + if first_installed is not None: + _send_servers_discovered_event(push_key, url, *first_installed, machine_id) + def _prepare_client_config(client: str, command: str, config_path: Path) -> tuple[dict | None, str | None, dict, int]: """Dispatch to the client-specific config preparation function. @@ -346,14 +358,22 @@ def _install_hooks( minted: bool, tenant_id: str, snyk_token: str, -) -> None: + machine_id: str, +) -> Path: """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) + command = _build_hook_command( + push_key, + url, + dest_path, + hook_client, + tenant_id=tenant_id, + machine_id=machine_id, + ) prepared_config, prepared_content, hooks_diff, preserved = _prepare_client_config(client, command, config_path) first_install = not script_existed @@ -370,6 +390,7 @@ def _install_hooks( push_key_changed=push_key_changed, current_checksum=current_checksum, new_checksum=new_checksum, + machine_id=machine_id, ): if not script_existed: dest_path.unlink(missing_ok=True) @@ -387,6 +408,7 @@ def _install_hooks( rich.print(f" Remote URL: [dim]{url}[/dim]") rich.print(f" Push Key: [yellow]{_mask_key(push_key)}[/yellow]") rich.print() + return dest_path def _prepare_claude_config(command: str, path: Path) -> tuple[dict, dict, int]: @@ -929,10 +951,92 @@ def _detect_cursor_install(path: Path = CURSOR_HOOKS_PATH) -> dict | None: # --------------------------------------------------------------------------- -# Test event +# Hook events # --------------------------------------------------------------------------- +def _servers_discovered_entries(clients_to_inspect: list[ClientToInspect]) -> list[dict]: + from agent_scan.models import InspectedPath, InspectedServer + from agent_scan.models.api.v20260710 import ScanPathRequest + + entries = [] + for client in clients_to_inspect: + servers: list[InspectedServer] = [] + for config_path, discovered in client.mcp_configs.items(): + if not isinstance(discovered, list): + continue + servers.extend( + InspectedServer(name=name, config_path=config_path, server=server) for name, server in discovered + ) + inspected_path = InspectedPath(client=client.name, path=client.client_path, servers=servers) + entries.append(ScanPathRequest.from_inspected(inspected_path).model_dump(mode="json")) + return entries + + +def _discover_servers_payload() -> 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=[]) + clients_to_inspect, _, _ = asyncio.run(pipelines.discover_clients_to_inspect(inspect_args)) + 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 IS_WINDOWS: + cmd = [ + "powershell", + "-File", + str(script_path), + "-Client", + hook_client, + "-PushKey", + push_key, + "-RemoteUrl", + url, + ] + if machine_id: + cmd.extend(["-MachineId", machine_id]) + env = None + else: + cmd = ["bash", str(script_path), "--client", hook_client] + env = { + **os.environ, + "PUSH_KEY": push_key, + "REMOTE_HOOKS_BASE_URL": url, + } + if machine_id: + env["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( push_key: str, url: str, @@ -945,10 +1049,9 @@ def _send_test_event( push_key_changed: bool = False, current_checksum: str | None = None, 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 - payload_dict: dict = {"hook_event_name": "hooksConfigured"} if hook_client == "claude-code" or hook_client == "codex": payload_dict["session_id"] = "hooks-setup" @@ -972,49 +1075,48 @@ def _send_test_event( 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) + 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, + script_path: Path, + machine_id: str, +) -> bool: + rich.print("[dim]Discovering MCP servers...[/dim]") 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() 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 + payload_dict: dict = { + "hook_event_name": "serversDiscovered", + "servers": servers, + } + if hook_client == "claude-code" or hook_client == "codex": + payload_dict["session_id"] = "hooks-setup" + else: + payload_dict["conversation_id"] = "hooks-setup" + redact_push_keys_in_data(payload_dict) + payload = json.dumps(payload_dict) + + ok, detail = _invoke_hook_script(script_path, hook_client, push_key, url, payload, machine_id) + 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 + # --------------------------------------------------------------------------- # Detection / filtering @@ -1225,24 +1327,50 @@ 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: +def _build_hook_command( + push_key: str, + url: str, + script_path: Path, + hook_client: str, + *, + tenant_id: str = "", + machine_id: str = "", +) -> str: if IS_WINDOWS: - return _build_hook_command_powershell(push_key, url, script_path, hook_client, tenant_id=tenant_id) + return _build_hook_command_powershell( + push_key, + url, + script_path, + hook_client, + tenant_id=tenant_id, + machine_id=machine_id, + ) parts = [ f"PUSH_KEY={_shell_quote(push_key)}", f"REMOTE_HOOKS_BASE_URL={_shell_quote(url)}", ] if tenant_id: parts.append(f"TENANT_ID={_shell_quote(tenant_id)}") + if machine_id: + parts.append(f"MACHINE_ID={_shell_quote(machine_id)}") parts.append(f"bash {_shell_quote(script_path.as_posix())}") parts.append(f"--client {hook_client}") return " ".join(parts) 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}'" + command = f"powershell -File '{script_path}' -Client {hook_client} -PushKey '{push_key}' -RemoteUrl '{url}'" + if machine_id: + command += f" -MachineId '{machine_id}'" + return command def _shell_quote(s: str) -> str: diff --git a/src/agent_scan/hooks/snyk-agent-guard.ps1 b/src/agent_scan/hooks/snyk-agent-guard.ps1 index 1a659ef3..125201ab 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,8 @@ if (-not $RemoteUrl) { exit 1 } +if (-not $MachineId) { $MachineId = $env:MACHINE_ID } + switch ($Client) { "claude-code" { $endpoint = "/hidden/agent-monitor/hooks/claude-code" @@ -88,8 +93,9 @@ function JsonEscape($s) { return $s } +$identifier = if ($MachineId) { $MachineId } else { $hostname } $xUser = '{{"hostname":"{0}","username":"{1}","identifier":"{2}"}}' -f ` - (JsonEscape $hostname), (JsonEscape $username), (JsonEscape $hostname) + (JsonEscape $hostname), (JsonEscape $username), (JsonEscape $identifier) # 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..cd29f03d 100755 --- a/src/agent_scan/hooks/snyk-agent-guard.sh +++ b/src/agent_scan/hooks/snyk-agent-guard.sh @@ -129,7 +129,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:-$hostname}")")" # Execute request local resp body http_code marker diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index 9f4158d6..469b7fe3 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() @@ -54,10 +65,12 @@ def test_guard_install_claude(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, + timeout=60, env={**os.environ, "PUSH_KEY": "test-pk-e2e"}, ) assert result.returncode == 0, f"guard install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" @@ -68,6 +81,14 @@ 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"] + assert [request["body"]["hook_event_name"] for request in _FakeHookServer.requests] == [ + "hooksConfigured", + "serversDiscovered", + ] + discovered = _FakeHookServer.requests[1] + assert discovered["body"]["session_id"] == "hooks-setup" + assert isinstance(discovered["body"]["servers"], list) + assert json.loads(discovered["headers"]["X-User"])["identifier"] == "e2e-machine-id" @pytest.mark.parametrize("agent_scan_cmd", ["uv", "binary"], indirect=True) def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): @@ -85,7 +106,7 @@ def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): ], capture_output=True, text=True, - timeout=30, + timeout=60, env={**os.environ, "PUSH_KEY": "test-pk-e2e"}, ) assert result.returncode == 0, f"guard install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 61e46db4..4a6e2814 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -11,10 +11,11 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +import agent_scan.guard as guard_module from agent_scan.guard import ( _PERMISSION_DENIED, ALL_CLIENTS, @@ -63,6 +64,9 @@ _write_codex_managed_config, _write_cursor_config, ) +from agent_scan.models import ClientToInspect, InspectedPath, InspectedServer, RemoteServer, StdioServer +from agent_scan.models.api.v20260710 import ScanPathRequest +from agent_scan.models.errors import CouldNotParseMCPConfig, FileNotFoundConfig from agent_scan.pushkeys import GuardEnabledAccessDeniedError # --------------------------------------------------------------------------- @@ -228,6 +232,26 @@ 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 + @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") @@ -1218,6 +1242,44 @@ def test_codex_endpoint(self, hook_server): 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_x_user_identifier_falls_back_to_hostname(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, + "HOSTNAME": "fallback-host", + }, + ) + assert result.returncode == 0, result.stderr + x_user = json.loads(_HookHandler.last_request["headers"]["X-User"]) + assert x_user["identifier"] == "fallback-host" + def test_missing_push_key_fails(self, hook_server): script = _get_script_path("snyk-agent-guard.sh") result = subprocess.run( @@ -1317,6 +1379,57 @@ 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_x_user_identifier_falls_back_to_hostname(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, result.stderr + x_user = json.loads(_HookHandler.last_request["headers"]["X-User"]) + assert x_user["identifier"] == x_user["hostname"] + def test_missing_push_key_fails(self, hook_server): script = _get_script_path("snyk-agent-guard.ps1") env = dict(__import__("os").environ) @@ -1549,7 +1662,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) @@ -1646,7 +1759,15 @@ def ctx(self): 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 +1782,7 @@ def _call(self, tmp_path, client="claude", hook_client="claude-code", minted=Fal minted, "tid-1", "snyk-tok", + machine_id, ) return config @@ -1675,6 +1797,27 @@ def test_copy_hook_script_called_with_config_path_only(self, ctx, tmp_path): config = self._call(tmp_path, client="claude", config_exists=True) ctx["copy"].assert_called_once_with(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_returns_installed_script_path(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 == ctx["dest"] + # --------------------------------------------------------------- # Client routing: each client calls its own prepare + write # --------------------------------------------------------------- @@ -1774,6 +1917,7 @@ def test_test_event_receives_diff(self, ctx, tmp_path): push_key_changed=False, current_checksum=None, new_checksum=_NEW_CHECKSUM, + machine_id="", ) def test_test_event_receives_empty_diff(self, ctx, tmp_path): @@ -1791,6 +1935,7 @@ def test_test_event_receives_empty_diff(self, ctx, tmp_path): push_key_changed=False, current_checksum=None, new_checksum=_NEW_CHECKSUM, + machine_id="", ) def test_test_event_not_first_install(self, ctx, tmp_path): @@ -1807,6 +1952,7 @@ def test_test_event_not_first_install(self, ctx, tmp_path): push_key_changed=False, current_checksum=_CURRENT_CHECKSUM, new_checksum=_NEW_CHECKSUM, + machine_id="", ) def test_test_event_push_key_changed(self, ctx, tmp_path): @@ -1824,6 +1970,7 @@ def test_test_event_push_key_changed(self, ctx, tmp_path): push_key_changed=True, current_checksum=None, new_checksum=_NEW_CHECKSUM, + machine_id="", ) def test_test_event_push_key_unchanged(self, ctx, tmp_path): @@ -1841,6 +1988,7 @@ def test_test_event_push_key_unchanged(self, ctx, tmp_path): push_key_changed=False, current_checksum=None, new_checksum=_NEW_CHECKSUM, + machine_id="", ) # --------------------------------------------------------------- @@ -2017,6 +2165,416 @@ def test_no_checksums_omits_hooks_script(self): assert "hooks_script" not in payload +class TestServersDiscoveredPayload: + @staticmethod + def _client(*, mcp_configs, name="claude code", path="/Users/me/.claude"): + return ClientToInspect(name=name, client_path=path, 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"}, + ) + clients = [ + self._client( + mcp_configs={ + "/Users/me/.claude.json": [("github", stdio)], + "/Users/me/project/.mcp.json": [("remote", remote)], + } + ), + self._client(mcp_configs={}, name="cursor", path="/Users/me/.cursor"), + ] + + result = guard_module._servers_discovered_entries(clients) + + assert [(entry["client"], entry["path"]) for entry in result] == [ + ("claude code", "/Users/me/.claude"), + ("cursor", "/Users/me/.cursor"), + ] + assert [(server["name"], server["config_path"]) for server in result[0]["servers"]] == [ + ("github", "/Users/me/.claude.json"), + ("remote", "/Users/me/project/.mcp.json"), + ] + assert result[1]["servers"] == [] + + def test_skips_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"] + + def test_empty_input_returns_empty_list(self): + assert guard_module._servers_discovered_entries([]) == [] + + def test_matches_scan_path_request_wire_shape(self): + 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 = ScanPathRequest.from_inspected(inspected).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 result == guard_module._servers_discovered_entries(clients) + + +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( + Path("/hook.sh"), "claude-code", "pk", "https://api.snyk.io", "{}", "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_omits_machine_id_when_unset(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(Path("/hook.sh"), "cursor", "pk", "https://api.snyk.io", "{}") + + assert result == (True, "") + assert "MACHINE_ID" not in run.call_args.kwargs["env"] + + @pytest.mark.parametrize("machine_id, expected_tail", [("", []), ("machine-42", ["-MachineId", "machine-42"])]) + def test_windows_invocation_machine_id_shape(self, machine_id, expected_tail): + 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 + ) + + assert result == (True, "") + assert run.call_args.args[0] == [ + "powershell", + "-File", + "C:/hook.ps1", + "-Client", + "codex", + "-PushKey", + "pk", + "-RemoteUrl", + "https://api.snyk.io", + *expected_tail, + ] + 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", "{}") + assert result == (False, "bad request") + + +class TestSendServersDiscoveredEvent: + @staticmethod + def _capture(hook_client="claude-code", entries=None, machine_id="machine-42"): + captured = {} + + def fake_run(cmd, *, input, **kwargs): + captured["cmd"] = cmd + captured["payload"] = json.loads(input) + captured["env"] = kwargs["env"] + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + + with ( + patch(f"{_G}._discover_servers_payload", return_value=[] if entries is None else entries), + patch("subprocess.run", side_effect=fake_run), + patch(f"{_G}.rich"), + ): + ok = guard_module._send_servers_discovered_event( + "pk-test", "https://api.snyk.io", hook_client, Path("/hook.sh"), 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"] == "serversDiscovered" + assert payload[id_key] == "hooks-setup" + assert ({"session_id", "conversation_id"} - {id_key}).isdisjoint(payload) + assert payload["servers"][0]["command"] == "PUSH_KEY='**REDACTED**'" + assert push_key not in json.dumps(payload) + assert captured["env"]["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_nonzero_exit_warns_and_returns_false(self): + completed = subprocess.CompletedProcess([], 2, stdout="", stderr="failed") + with ( + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch("subprocess.run", return_value=completed), + patch(f"{_G}.rich") as rich_mock, + ): + result = guard_module._send_servers_discovered_event("pk", "url", "cursor", Path("/hook.sh"), "") + assert result is False + assert "failed" in rich_mock.print.call_args.args[0] + + def test_timeout_warns_and_returns_false(self): + with ( + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch("subprocess.run", side_effect=subprocess.TimeoutExpired("bash", 15)), + patch(f"{_G}.rich") as rich_mock, + ): + result = guard_module._send_servers_discovered_event("pk", "url", "cursor", Path("/hook.sh"), "") + assert result is False + assert "timeout" in rich_mock.print.call_args.args[0] + + def test_discovery_exception_does_not_invoke_script(self): + with ( + patch(f"{_G}._discover_servers_payload", side_effect=RuntimeError("discovery failed")), + patch("subprocess.run") as run, + patch(f"{_G}.rich") as rich_mock, + ): + result = guard_module._send_servers_discovered_event("pk", "url", "cursor", Path("/hook.sh"), "") + assert result is False + run.assert_not_called() + assert "discovery failed" in rich_mock.print.call_args.args[0] + + +class TestGuardInstallMachineIdCli: + @pytest.mark.parametrize("flag", ["--machine-id", "--control-identifier"]) + def test_guard_install_accepts_machine_id_aliases(self, flag, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr(sys, "argv", ["agent-scan", "guard", "install", "claude", flag, "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" + + +class TestRunInstallSendsServersDiscovered: + @staticmethod + def _args(tmp_path, *, client="claude", file_override=True, managed=False, machine_id=None): + 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_using_installed_script(self, tmp_path, monkeypatch): + 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", script, "machine-42") + + 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:4] == ("cursor", script) + + 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): + 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:4] == ("claude-code", scripts[0]) + 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"), (None, None, "")], + ) + 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 + + 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 # =================================================================== From 2c1fd067654ba5a2bfa5b8c8581d3117f6b539ba Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 11:54:38 +0200 Subject: [PATCH 02/58] fix: escape PowerShell machine identifiers --- src/agent_scan/guard.py | 3 ++- tests/unit/test_guard.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 285e8824..6e3cebd8 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -1369,7 +1369,8 @@ def _build_hook_command_powershell( ) -> str: command = f"powershell -File '{script_path}' -Client {hook_client} -PushKey '{push_key}' -RemoteUrl '{url}'" if machine_id: - command += f" -MachineId '{machine_id}'" + escaped_machine_id = machine_id.replace("'", "''") + command += f" -MachineId '{escaped_machine_id}'" return command diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 4a6e2814..c6d897e3 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -252,6 +252,12 @@ 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 + @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") From 0ad0809c466fbcc35c68dc0d35cc57f50f56a031 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 11:59:38 +0200 Subject: [PATCH 03/58] fix: scope explicit flags to active subparser --- src/agent_scan/cli.py | 16 +++++++++++++++- tests/unit/test_cli_config_file.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index 1ac071b5..deeb5c4b 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -227,6 +227,20 @@ def _iter_all_actions(parser: argparse.ArgumentParser): yield action +def _iter_active_actions(parser: argparse.ArgumentParser, argv: list[str]): + """Yield actions from the root parser and the subparser path selected by ``argv``.""" + for action in parser._actions: + if not isinstance(action, argparse._SubParsersAction): + yield action + continue + + for index, token in enumerate(argv): + subparser = action.choices.get(token) + if subparser is not None: + yield from _iter_active_actions(subparser, argv[index + 1 :]) + break + + def explicitly_provided_dests(parser: argparse.ArgumentParser, argv: list[str]) -> set[str]: """ Return the set of argument ``dest`` names the user passed explicitly on the @@ -239,7 +253,7 @@ def explicitly_provided_dests(parser: argparse.ArgumentParser, argv: list[str]) BooleanOptionalAction (``--skills`` / ``--no-skills`` both map to ``skills``). """ option_to_dest: dict[str, str] = {} - for action in _iter_all_actions(parser): + for action in _iter_active_actions(parser, argv): for option in action.option_strings: option_to_dest[option] = action.dest diff --git a/tests/unit/test_cli_config_file.py b/tests/unit/test_cli_config_file.py index 523fe1ec..03577602 100644 --- a/tests/unit/test_cli_config_file.py +++ b/tests/unit/test_cli_config_file.py @@ -92,6 +92,22 @@ def test_boolean_optional_both_spellings_map_to_same_dest(self): assert "skills" in explicitly_provided_dests(parser, ["scan", "--no-skills"]) assert "skills" in explicitly_provided_dests(parser, ["scan", "--skills"]) + def test_uses_destination_from_active_subparser_when_option_aliases_collide(self): + parser = _build_parser() + subparsers = next(action for action in parser._actions if isinstance(action, argparse._SubParsersAction)) + guard_parser = subparsers.add_parser("guard", allow_abbrev=False) + guard_subparsers = guard_parser.add_subparsers(dest="guard_command") + guard_install_parser = guard_subparsers.add_parser("install", allow_abbrev=False) + guard_install_parser.add_argument("--machine-id", "--control-identifier", dest="machine_id", default=None) + + provided = explicitly_provided_dests( + parser, + ["scan", "--control-server", "https://example.com", "--control-identifier", "legacy-id"], + ) + + assert "control_identifier" in provided + assert "machine_id" not in provided + class TestAbbreviationDisabled: """main() sets allow_abbrev=False so prefix abbreviations are rejected, which From 0a8c03e180f947c09034de54e34de6caa07ebc80 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 12:37:29 +0200 Subject: [PATCH 04/58] test: keep run-install tests hermetic from discovery event _run_install tests that mock _install_hooks were running the real post-install serversDiscovered send: actual machine discovery plus a hook-script invocation on a mock path (~1.2s per test, environment- dependent). Patch _send_servers_discovered_event autouse in the three affected classes, and pin that clients with only unparseable configs still emit an empty-servers entry. --- tests/unit/test_guard.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index c6d897e3..e36b0bb7 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -1607,6 +1607,13 @@ 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): + 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) @@ -2223,6 +2230,18 @@ def test_skips_config_discovery_errors(self): assert [server["name"] for server in result[0]["servers"]] == ["good"] + 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", [])] + def test_empty_input_returns_empty_list(self): assert guard_module._servers_discovered_entries([]) == [] @@ -3096,6 +3115,13 @@ 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): + 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) @@ -3281,6 +3307,13 @@ 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): + yield + @staticmethod def _fake_paths(tmp_path, installed_clients): """Build a _CLIENT_INSTALL_PATHS dict where only *installed_clients* have real dirs.""" From 2e332a44668b45c84a3c4872f369377ebc1005f8 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 13:11:27 +0200 Subject: [PATCH 05/58] feat: refresh discovered servers on Claude sessions --- src/agent_scan/cli.py | 21 ++ src/agent_scan/guard.py | 155 ++++++++- .../hooks/snyk-agent-guard-discover.sh | 3 + tests/e2e/test_guard_install.py | 26 ++ tests/unit/test_guard.py | 326 ++++++++++++++++++ 5 files changed, 519 insertions(+), 12 deletions(-) create mode 100755 src/agent_scan/hooks/snyk-agent-guard-discover.sh diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index deeb5c4b..d0d51db6 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -987,6 +987,27 @@ 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 through the installed hooks " + "(used by the async Claude Code SessionStart hook)" + ), + ) + 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( + "--file", + type=str, + default=None, + help="Override the Claude settings file path (default: ~/.claude/settings.json)", + ) + guard_uninstall_parser = guard_subparsers.add_parser( "uninstall", allow_abbrev=False, diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 6e3cebd8..0ad91a8d 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -116,6 +116,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: @@ -293,7 +295,42 @@ def _run_install(args) -> None: _send_servers_discovered_event(push_key, url, *first_installed, machine_id) -def _prepare_client_config(client: str, command: str, config_path: Path) -> tuple[dict | None, str | None, dict, int]: +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 + machine_id = (os.environ.get("MACHINE_ID", "") or "").strip() + config_path = Path(getattr(args, "file", None) or CLAUDE_SETTINGS_PATH) + script_path = config_path.parent / "hooks" / "snyk-agent-guard.sh" + if not script_path.exists(): + rich.print( + f"[bold red]Error:[/bold red] Agent Guard forwarding script not found: {script_path}. " + "Run guard install claude first." + ) + return 1 + + success = _send_servers_discovered_event( + push_key, + url, + "claude-code", + script_path, + machine_id, + event_name="SessionStartServerDiscovery", + session_marker="session-start-server-discovery", + ) + return 0 if success else 1 + + +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). @@ -302,7 +339,9 @@ 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) elif client == "codex": @@ -374,7 +413,21 @@ def _install_hooks( tenant_id=tenant_id, machine_id=machine_id, ) - prepared_config, prepared_content, hooks_diff, preserved = _prepare_client_config(client, command, config_path) + discover_command = None + if client == "claude" and not IS_WINDOWS: + discover_command = _build_discover_hook_command( + push_key, + url, + dest_path.with_name("snyk-agent-guard-discover.sh"), + tenant_id=tenant_id, + machine_id=machine_id, + ) + prepared_config, prepared_content, hooks_diff, preserved = _prepare_client_config( + client, + command, + config_path, + discover_command=discover_command, + ) first_install = not script_existed config_changed = bool(hooks_diff["added"] or hooks_diff["modified"] or hooks_diff["removed"]) @@ -411,7 +464,12 @@ def _install_hooks( return dest_path -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). @@ -434,6 +492,19 @@ def _prepare_claude_config(command: str, path: Path) -> tuple[dict, dict, int]: existing.append(group) hooks[event] = existing + if discover_command: + 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 @@ -1089,6 +1160,9 @@ def _send_servers_discovered_event( hook_client: str, script_path: Path, machine_id: str, + *, + event_name: str = "serversDiscovered", + session_marker: str = "hooks-setup", ) -> bool: rich.print("[dim]Discovering MCP servers...[/dim]") try: @@ -1098,13 +1172,13 @@ def _send_servers_discovered_event( return False payload_dict: dict = { - "hook_event_name": "serversDiscovered", + "hook_event_name": event_name, "servers": servers, } if hook_client == "claude-code" or hook_client == "codex": - payload_dict["session_id"] = "hooks-setup" + payload_dict["session_id"] = session_marker else: - payload_dict["conversation_id"] = "hooks-setup" + payload_dict["conversation_id"] = session_marker redact_push_keys_in_data(payload_dict) payload = json.dumps(payload_dict) @@ -1358,6 +1432,45 @@ def _build_hook_command( return " ".join(parts) +def _agent_scan_bin() -> str | None: + if "AGENT_SCAN_BIN" in os.environ: + return os.environ["AGENT_SCAN_BIN"] + if getattr(sys, "frozen", False): + return str(Path(sys.executable).resolve()) + + invoked_path = Path(sys.argv[0]) + if invoked_path.name == "snyk-agent-scan" and invoked_path.is_file() and os.access(invoked_path, os.X_OK): + return str(invoked_path.resolve()) + + console_script = Path(sys.executable).parent / "snyk-agent-scan" + if console_script.is_file() and os.access(console_script, os.X_OK): + return str(console_script.resolve()) + return None + + +def _build_discover_hook_command( + push_key: str, + url: str, + script_path: Path, + *, + tenant_id: str = "", + machine_id: str = "", +) -> str: + parts = [ + f"PUSH_KEY={_shell_quote(push_key)}", + f"REMOTE_HOOKS_BASE_URL={_shell_quote(url)}", + ] + if tenant_id: + parts.append(f"TENANT_ID={_shell_quote(tenant_id)}") + if machine_id: + parts.append(f"MACHINE_ID={_shell_quote(machine_id)}") + agent_scan_bin = _agent_scan_bin() + if agent_scan_bin is not None: + parts.append(f"AGENT_SCAN_BIN={_shell_quote(agent_scan_bin)}") + parts.append(f"bash {_shell_quote(script_path.as_posix())}") + return " ".join(parts) + + def _build_hook_command_powershell( push_key: str, url: str, @@ -1417,6 +1530,16 @@ def _copy_hook_script(config_path: Path) -> tuple[Path, bool, bool, str | None, new_content = source.read_bytes().replace(b"__AGENT_SCAN_VERSION__", version_info.encode()) new_checksum = hashlib.sha256(new_content).hexdigest() + if not IS_WINDOWS: + discover_name = "snyk-agent-guard-discover.sh" + discover_source = hook_pkg.joinpath(discover_name) + discover_dest = dest_dir / discover_name + discover_content = discover_source.read_bytes() + if not discover_dest.exists() or discover_dest.read_bytes() != discover_content: + discover_dest.write_bytes(discover_content) + rich.print(f"[green]\u2713[/green] Copied hook script to [dim]{discover_dest}[/dim]") + discover_dest.chmod(discover_dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + if existed and current_checksum == new_checksum: return dest, existed, False, current_checksum, new_checksum @@ -1428,11 +1551,19 @@ def _copy_hook_script(config_path: Path) -> tuple[Path, bool, bool, str | None, 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]") + script_names = ( + ["snyk-agent-guard.ps1"] + if IS_WINDOWS + else [ + "snyk-agent-guard.sh", + "snyk-agent-guard-discover.sh", + ] + ) + for script_name in script_names: + dest = dest_dir / script_name + 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/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh new file mode 100755 index 00000000..873dd9d0 --- /dev/null +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index 469b7fe3..a0cc85b6 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -81,6 +81,12 @@ 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] + assert "snyk-agent-guard-discover.sh" in discovery_groups[0]["hooks"][0]["command"] assert [request["body"]["hook_event_name"] for request in _FakeHookServer.requests] == [ "hooksConfigured", "serversDiscovered", @@ -90,6 +96,26 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): assert isinstance(discovered["body"]["servers"], list) assert json.loads(discovered["headers"]["X-User"])["identifier"] == "e2e-machine-id" + discover_result = subprocess.run( + [*agent_scan_cmd, "guard", "discover", "--file", str(config_file)], + 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) + @pytest.mark.parametrize("agent_scan_cmd", ["uv", "binary"], indirect=True) def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): config_file = tmp_path / "hooks.json" diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index e36b0bb7..10001aef 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -4,6 +4,7 @@ import base64 import json +import os import shutil import subprocess import sys @@ -282,6 +283,184 @@ def test_roundtrip_extract(self): assert _extract_env_from_cmd(cmd, "TENANT_ID") == "t-1" +class TestAgentScanBin: + def test_environment_override_wins(self, monkeypatch): + monkeypatch.setenv("AGENT_SCAN_BIN", "custom agent scan") + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", "/ignored/frozen-binary") + + assert guard_module._agent_scan_bin() == "custom agent scan" + + def test_frozen_binary_uses_resolved_executable(self, tmp_path, monkeypatch): + executable = tmp_path / "dist" / "agent-scan" + monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", str(executable)) + + assert guard_module._agent_scan_bin() == str(executable.resolve()) + + def test_console_script_uses_resolved_argv_zero(self, tmp_path, monkeypatch): + executable = tmp_path / "snyk-agent-scan" + executable.write_text("#!/bin/sh\n") + executable.chmod(0o755) + monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr(sys, "argv", [str(executable)]) + monkeypatch.setattr(sys, "executable", str(tmp_path / "python")) + + assert guard_module._agent_scan_bin() == str(executable.resolve()) + + def test_venv_console_script_sibling_is_used_for_dev_invocation(self, tmp_path, monkeypatch): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + executable = bin_dir / "snyk-agent-scan" + executable.write_text("#!/bin/sh\n") + executable.chmod(0o755) + monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr(sys, "argv", [str(tmp_path / "src" / "agent_scan" / "cli.py")]) + monkeypatch.setattr(sys, "executable", str(bin_dir / "python")) + + assert guard_module._agent_scan_bin() == str(executable.resolve()) + + def test_returns_none_when_no_executable_matches(self, tmp_path, monkeypatch): + monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr(sys, "argv", [str(tmp_path / "cli.py")]) + monkeypatch.setattr(sys, "executable", str(tmp_path / "bin" / "python")) + + assert guard_module._agent_scan_bin() is None + + +class TestBuildDiscoverHookCommand: + def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): + with patch(f"{_G}._agent_scan_bin", return_value="/opt/Snyk's bin/snyk-agent-scan"): + command = guard_module._build_discover_hook_command( + "pk", + "https://api.snyk.io", + Path("/x/snyk-agent-guard-discover.sh"), + 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='tenant'" in command + assert "MACHINE_ID='machine'" in command + assert "AGENT_SCAN_BIN='/opt/Snyk'\"'\"'s bin/snyk-agent-scan'" in command + assert command.endswith("bash '/x/snyk-agent-guard-discover.sh'") + assert _is_agent_scan_command(command) + + def test_omits_agent_scan_binary_when_unresolved(self): + with patch(f"{_G}._agent_scan_bin", return_value=None): + command = guard_module._build_discover_hook_command( + "pk", "https://api.snyk.io", Path("/x/snyk-agent-guard-discover.sh") + ) + + assert "AGENT_SCAN_BIN" not in command + + +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): + 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_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_claude_config(settings, path, preserved) + + _, diff, _ = _prepare_claude_config( + AGENT_SCAN_CMD, + path, + discover_command=self.discover_command, + ) + + assert diff == {"added": {}, "modified": {}, "removed": {}} + + +@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" + + main_script, *_ = guard_module._copy_hook_script(config) + discover_script = main_script.with_name("snyk-agent-guard-discover.sh") + + assert discover_script.read_text() == ( + '#!/usr/bin/env bash\nset -euo pipefail\nexec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover\n' + ) + assert os.access(discover_script, os.X_OK) + + def test_copy_restores_missing_discovery_script_when_forwarder_is_current(self, tmp_path): + config = tmp_path / "settings.json" + main_script, *_ = guard_module._copy_hook_script(config) + discover_script = main_script.with_name("snyk-agent-guard-discover.sh") + discover_script.unlink() + + guard_module._copy_hook_script(config) + + assert discover_script.exists() + + def test_remove_deletes_both_scripts(self, tmp_path): + config = tmp_path / "settings.json" + main_script, *_ = guard_module._copy_hook_script(config) + discover_script = main_script.with_name("snyk-agent-guard-discover.sh") + discover_script.write_text("discovery") + + 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_claude_config(settings, config, preserved) + main_script, *_ = guard_module._copy_hook_script(config) + discover_script = main_script.with_name("snyk-agent-guard-discover.sh") + + _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 TestParseCommandInfo: def test_full_command(self): info = _parse_command_info(AGENT_SCAN_CMD, ["PreToolUse", "Stop"]) @@ -1748,6 +1927,7 @@ def ctx(self): targets = { "copy": (f"{_G}._copy_hook_script", (dest, True, False, _CURRENT_CHECKSUM, _NEW_CHECKSUM)), "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)), @@ -1815,6 +1995,28 @@ def test_machine_id_forwarded_to_command_and_test_event(self, ctx, tmp_path): 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): + self._call(tmp_path, client="claude", machine_id="machine-42") + + ctx["build_discover"].assert_called_once() + assert ctx["build_discover"].call_args.kwargs == { + "tenant_id": "tid-1", + "machine_id": "machine-42", + } + assert ctx["prep_claude"].call_args.kwargs["discover_command"] == "discover-cmd" + + def test_cursor_does_not_build_discovery_hook(self, ctx, tmp_path): + self._call(tmp_path, client="cursor", hook_client="cursor") + + ctx["build_discover"].assert_not_called() + + def test_windows_claude_does_not_build_discovery_hook(self, ctx, tmp_path): + with patch(f"{_G}.IS_WINDOWS", True): + self._call(tmp_path, client="claude") + + ctx["build_discover"].assert_not_called() + assert ctx["prep_claude"].call_args.kwargs["discover_command"] is None + def test_returns_installed_script_path(self, ctx, tmp_path): result = _install_hooks( "claude", @@ -2408,6 +2610,32 @@ def test_empty_discovery_is_still_sent(self): assert ok is True assert captured["payload"]["servers"] == [] + def test_event_name_and_session_marker_can_be_overridden(self): + captured = {} + + def fake_run(cmd, *, input, **kwargs): + captured["payload"] = json.loads(input) + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + + with ( + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch("subprocess.run", side_effect=fake_run), + patch(f"{_G}.rich"), + ): + ok = guard_module._send_servers_discovered_event( + "pk", + "https://api.snyk.io", + "claude-code", + Path("/hook.sh"), + "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_nonzero_exit_warns_and_returns_false(self): completed = subprocess.CompletedProcess([], 2, stdout="", stderr="failed") with ( @@ -2455,6 +2683,104 @@ def test_guard_install_accepts_machine_id_aliases(self, flag, monkeypatch): assert run.call_args.args[0].machine_id == "machine-42" +class TestGuardDiscoverCli: + def test_parses_url_and_file(self, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr( + sys, + "argv", + ["agent-scan", "guard", "discover", "--url", "https://hooks.example", "--file", "/tmp/settings.json"], + ) + 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.file == "/tmp/settings.json" + + +class TestRunDiscover: + @staticmethod + def _args(config: Path, url=None): + return SimpleNamespace(guard_command="discover", url=url, file=str(config)) + + @staticmethod + def _write_forwarder(config: Path): + script = config.parent / "hooks" / "snyk-agent-guard.sh" + script.parent.mkdir(parents=True) + script.write_text("#!/bin/sh\n") + return script + + def test_happy_path_sends_session_start_discovery_from_environment(self, tmp_path, monkeypatch): + config = tmp_path / "custom" / "settings.json" + script = self._write_forwarder(config) + captured = {} + + def fake_run(cmd, *, input, **kwargs): + captured["cmd"] = cmd + captured["payload"] = json.loads(input) + captured["env"] = kwargs["env"] + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + + 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("subprocess.run", side_effect=fake_run), + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config)) + + assert result == 0 + assert captured["cmd"] == ["bash", str(script), "--client", "claude-code"] + assert captured["payload"] == { + "hook_event_name": "SessionStartServerDiscovery", + "servers": [], + "session_id": "session-start-server-discovery", + } + assert captured["env"]["PUSH_KEY"] == "env-pk" + assert captured["env"]["REMOTE_HOOKS_BASE_URL"] == "https://env-hooks.example" + assert captured["env"]["MACHINE_ID"] == "env-machine" + + def test_explicit_url_overrides_environment(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + self._write_forwarder(config) + 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("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, url="https://flag-hooks.example")) + + assert result == 0 + assert run.call_args.kwargs["env"]["REMOTE_HOOKS_BASE_URL"] == "https://flag-hooks.example" + + def test_missing_push_key_returns_one_without_invoking_script(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + self._write_forwarder(config) + monkeypatch.delenv("PUSH_KEY", raising=False) + with patch("subprocess.run") as run: + result = guard_module.run_guard(self._args(config)) + + assert result == 1 + run.assert_not_called() + + def test_missing_forwarding_script_returns_one(self, tmp_path, monkeypatch): + monkeypatch.setenv("PUSH_KEY", "env-pk") + with patch("subprocess.run") as run: + result = guard_module.run_guard(self._args(tmp_path / "settings.json")) + + assert result == 1 + run.assert_not_called() + + class TestRunInstallSendsServersDiscovered: @staticmethod def _args(tmp_path, *, client="claude", file_override=True, managed=False, machine_id=None): From 34b80b426af70821b43f08b96b2613fe6cd9b6db Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 13:22:35 +0200 Subject: [PATCH 06/58] test: gate Claude discovery flow to POSIX --- tests/e2e/test_guard_install.py | 52 +++++++++++++++++---------------- tests/unit/test_guard.py | 15 ++++++---- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index a0cc85b6..eb88f5de 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -81,12 +81,13 @@ 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] - assert "snyk-agent-guard-discover.sh" in discovery_groups[0]["hooks"][0]["command"] + if os.name != "nt": + 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] + assert "snyk-agent-guard-discover.sh" in discovery_groups[0]["hooks"][0]["command"] assert [request["body"]["hook_event_name"] for request in _FakeHookServer.requests] == [ "hooksConfigured", "serversDiscovered", @@ -96,25 +97,26 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): assert isinstance(discovered["body"]["servers"], list) assert json.loads(discovered["headers"]["X-User"])["identifier"] == "e2e-machine-id" - discover_result = subprocess.run( - [*agent_scan_cmd, "guard", "discover", "--file", str(config_file)], - 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) + if os.name != "nt": + discover_result = subprocess.run( + [*agent_scan_cmd, "guard", "discover", "--file", str(config_file)], + 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) @pytest.mark.parametrize("agent_scan_cmd", ["uv", "binary"], indirect=True) def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 10001aef..48695985 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -10,7 +10,7 @@ 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 AsyncMock, MagicMock, patch @@ -1996,7 +1996,8 @@ def test_machine_id_forwarded_to_command_and_test_event(self, ctx, tmp_path): assert ctx["test_event"].call_args.kwargs["machine_id"] == "machine-42" def test_claude_builds_and_prepares_async_discovery_hook(self, ctx, tmp_path): - self._call(tmp_path, client="claude", machine_id="machine-42") + 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 == { @@ -2519,7 +2520,7 @@ def test_posix_invocation_sets_machine_id(self, monkeypatch): 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( - Path("/hook.sh"), "claude-code", "pk", "https://api.snyk.io", "{}", "machine-42" + PurePosixPath("/hook.sh"), "claude-code", "pk", "https://api.snyk.io", "{}", "machine-42" ) assert result == (True, "") @@ -2531,7 +2532,9 @@ def test_posix_invocation_omits_machine_id_when_unset(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(Path("/hook.sh"), "cursor", "pk", "https://api.snyk.io", "{}") + result = guard_module._invoke_hook_script( + PurePosixPath("/hook.sh"), "cursor", "pk", "https://api.snyk.io", "{}" + ) assert result == (True, "") assert "MACHINE_ID" not in run.call_args.kwargs["env"] @@ -2548,7 +2551,7 @@ def test_windows_invocation_machine_id_shape(self, machine_id, expected_tail): assert run.call_args.args[0] == [ "powershell", "-File", - "C:/hook.ps1", + str(Path("C:/hook.ps1")), "-Client", "codex", "-PushKey", @@ -2578,6 +2581,7 @@ def fake_run(cmd, *, input, **kwargs): return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") with ( + patch(f"{_G}.IS_WINDOWS", False), patch(f"{_G}._discover_servers_payload", return_value=[] if entries is None else entries), patch("subprocess.run", side_effect=fake_run), patch(f"{_G}.rich"), @@ -2703,6 +2707,7 @@ def test_parses_url_and_file(self, monkeypatch): assert args.file == "/tmp/settings.json" +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only Claude discovery hook") class TestRunDiscover: @staticmethod def _args(config: Path, url=None): From c30bbd9bbe5c032a7ccd995f6b6eac5889207920 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 13:39:59 +0200 Subject: [PATCH 07/58] fix: report hook-script update when only the discovery script changed _copy_hook_script returned was_updated=False whenever the forwarding script was already current, so an install that only restored or updated snyk-agent-guard-discover.sh printed 'hook integration up to date'. Track the discovery-script write and include it in the returned flag. --- src/agent_scan/guard.py | 10 +++++++--- tests/unit/test_guard.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 0ad91a8d..8c7008b9 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -1507,10 +1507,12 @@ def _compact_events(events: list[str]) -> str: 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. + """Copy bundled hook scripts to a hooks/ dir next to the config file. Returns (path, already_existed, was_updated, current_checksum, new_checksum). - current_checksum is None when the script did not exist before. + All values describe the forwarding script, except ``was_updated``, which is + True when either the forwarding or the discovery script was written. + current_checksum is None when the forwarding script did not exist before. """ dest_dir = config_path.parent / "hooks" @@ -1530,6 +1532,7 @@ def _copy_hook_script(config_path: Path) -> tuple[Path, bool, bool, str | None, new_content = source.read_bytes().replace(b"__AGENT_SCAN_VERSION__", version_info.encode()) new_checksum = hashlib.sha256(new_content).hexdigest() + discover_updated = False if not IS_WINDOWS: discover_name = "snyk-agent-guard-discover.sh" discover_source = hook_pkg.joinpath(discover_name) @@ -1538,10 +1541,11 @@ def _copy_hook_script(config_path: Path) -> tuple[Path, bool, bool, str | None, if not discover_dest.exists() or discover_dest.read_bytes() != discover_content: discover_dest.write_bytes(discover_content) rich.print(f"[green]\u2713[/green] Copied hook script to [dim]{discover_dest}[/dim]") + discover_updated = True discover_dest.chmod(discover_dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) if existed and current_checksum == new_checksum: - return dest, existed, False, current_checksum, new_checksum + return dest, existed, discover_updated, current_checksum, new_checksum dest.write_bytes(new_content) dest.chmod(dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 48695985..e78b2307 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -429,6 +429,23 @@ def test_copy_restores_missing_discovery_script_when_forwarder_is_current(self, 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._copy_hook_script(config) + main_script.with_name("snyk-agent-guard-discover.sh").unlink() + + _, _, was_updated, *_ = guard_module._copy_hook_script(config) + + assert was_updated is True + + def test_copy_reports_no_update_when_both_scripts_are_current(self, tmp_path): + config = tmp_path / "settings.json" + guard_module._copy_hook_script(config) + + _, _, was_updated, *_ = guard_module._copy_hook_script(config) + + assert was_updated is False + def test_remove_deletes_both_scripts(self, tmp_path): config = tmp_path / "settings.json" main_script, *_ = guard_module._copy_hook_script(config) From 088c6b51b7ee9fedf321313a0c76ec6bb23702be Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 15:07:12 +0200 Subject: [PATCH 08/58] feat: discover explicit project folders --- src/agent_scan/agents/__init__.py | 4 +- src/agent_scan/agents/base.py | 9 +- src/agent_scan/agents/opencode.py | 2 +- src/agent_scan/cli.py | 30 +++ src/agent_scan/guard.py | 23 ++- .../hooks/snyk-agent-guard-discover.sh | 2 +- src/agent_scan/pipelines.py | 17 +- tests/unit/test_agent_discovery.py | 188 ++++++++++++++++++ tests/unit/test_cli_interactivity.py | 38 ++++ tests/unit/test_cli_parsing.py | 28 ++- tests/unit/test_guard.py | 128 +++++++++++- 11 files changed, 454 insertions(+), 15 deletions(-) diff --git a/src/agent_scan/agents/__init__.py b/src/agent_scan/agents/__init__.py index dd5943f8..2b161cab 100644 --- a/src/agent_scan/agents/__init__.py +++ b/src/agent_scan/agents/__init__.py @@ -37,7 +37,7 @@ } -def find_discoverers(home_directory: Path | None) -> list[AgentDiscoverer]: +def find_discoverers(home_directory: Path | None, project_folders: list[Path] | None = 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 @@ -48,7 +48,7 @@ def find_discoverers(home_directory: Path | None) -> list[AgentDiscoverer]: """ found: list[AgentDiscoverer] = [] for cls in DISCOVERERS.values(): - discoverer = cls(home_directory) + discoverer = cls(home_directory, project_folders) try: exists = discoverer.client_exists() is not None except Exception: diff --git a/src/agent_scan/agents/base.py b/src/agent_scan/agents/base.py index b8d3208d..55b6c640 100644 --- a/src/agent_scan/agents/base.py +++ b/src/agent_scan/agents/base.py @@ -145,12 +145,13 @@ class AgentDiscoverer(ABC): name: str = "" - def __init__(self, home_directory: Path | None) -> None: + def __init__(self, home_directory: Path | None, project_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() + self.extra_project_folders = list(project_folders or []) # 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 @@ -454,6 +455,10 @@ def _discover_project_folders(self) -> list[Path]: """ return [] + def _all_project_folders(self) -> list[Path]: + """Agent-recorded project roots followed by explicitly supplied roots.""" + return list(dict.fromkeys([*self._discover_project_folders(), *self.extra_project_folders])) + def _project_paths_with_ancestors(self) -> list[Path]: """Project roots plus every ancestor up to filesystem root, deduplicated. @@ -467,7 +472,7 @@ def _project_paths_with_ancestors(self) -> list[Path]: return self._project_paths_cache seen: set[Path] = set() result: list[Path] = [] - for project_path in self._discover_project_folders(): + for project_path in self._all_project_folders(): cur = project_path while True: if cur not in seen: diff --git a/src/agent_scan/agents/opencode.py b/src/agent_scan/agents/opencode.py index fb3c8de5..2516cafd 100644 --- a/src/agent_scan/agents/opencode.py +++ b/src/agent_scan/agents/opencode.py @@ -613,7 +613,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_project_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/cli.py b/src/agent_scan/cli.py index d0d51db6..6be82f9e 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -14,6 +14,7 @@ import os import sys from dataclasses import dataclass +from pathlib import Path import psutil import rich @@ -479,6 +480,14 @@ def apply_config_file(parser: argparse.ArgumentParser, args: argparse.Namespace, def add_common_arguments(parser): """Add arguments that are common to multiple commands.""" + parser.add_argument( + "--project-folder", + action="append", + dest="project_folders", + default=[], + metavar="FOLDER", + help="Additional project folder to include in MCP server and skills discovery (repeatable)", + ) parser.add_argument( "--storage-file", type=str, @@ -1007,6 +1016,23 @@ def main(): default=None, help="Override the Claude settings file path (default: ~/.claude/settings.json)", ) + guard_discover_parser.add_argument( + "--project-folder", + action="append", + dest="project_folders", + default=[], + metavar="FOLDER", + help="Additional project folder to include in MCP server discovery (repeatable)", + ) + guard_discover_parser.add_argument( + "--hook-with-cwd-payload-stdin", + action="store_true", + default=False, + help=( + "Read the Claude Code hook JSON payload from stdin and include its cwd as a project folder " + "(used by the SessionStart hook)" + ), + ) guard_uninstall_parser = guard_subparsers.add_parser( "uninstall", @@ -1177,6 +1203,9 @@ async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanRespo server_timeout: int = args.server_timeout if hasattr(args, "server_timeout") else 10 files: list[str] | None = args.files if hasattr(args, "files") else None + project_folders = [str(Path(path).expanduser()) for path in getattr(args, "project_folders", []) or []] + if files and project_folders: + rich.print("[yellow]Warning:[/yellow] --project-folder is ignored when explicit files are provided.") scan_skills: bool = hasattr(args, "skills") and args.skills tokens: list[TokenAndClientInfo] = [] if hasattr(args, "mcp_oauth_tokens_path") and args.mcp_oauth_tokens_path: @@ -1189,6 +1218,7 @@ async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanRespo paths=files, all_users=scan_all_users, scan_skills=scan_skills, + project_folders=project_folders, ) # 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 8c7008b9..f55ddf93 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -312,6 +312,16 @@ def _run_discover(args) -> int: ) return 1 + project_folders = list(getattr(args, "project_folders", None) or []) + if getattr(args, "hook_with_cwd_payload_stdin", False): + try: + hook_payload = json.loads(sys.stdin.read(1024 * 1024)) + cwd = hook_payload.get("cwd") if isinstance(hook_payload, dict) else None + if isinstance(cwd, str) and cwd: + project_folders.append(cwd) + except Exception: + pass + success = _send_servers_discovered_event( push_key, url, @@ -320,6 +330,7 @@ def _run_discover(args) -> int: machine_id, event_name="SessionStartServerDiscovery", session_marker="session-start-server-discovery", + project_folders=project_folders, ) return 0 if success else 1 @@ -1044,13 +1055,18 @@ def _servers_discovered_entries(clients_to_inspect: list[ClientToInspect]) -> li return entries -def _discover_servers_payload() -> list[dict]: +def _discover_servers_payload(project_folders: list[str] | None = None) -> 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=[]) + inspect_args = pipelines.InspectArgs( + timeout=0, + tokens=[], + paths=[], + project_folders=project_folders or [], + ) clients_to_inspect, _, _ = asyncio.run(pipelines.discover_clients_to_inspect(inspect_args)) return _servers_discovered_entries(clients_to_inspect) @@ -1163,10 +1179,11 @@ def _send_servers_discovered_event( *, event_name: str = "serversDiscovered", session_marker: str = "hooks-setup", + project_folders: list[str] | None = None, ) -> bool: rich.print("[dim]Discovering MCP servers...[/dim]") try: - servers = _discover_servers_payload() + servers = _discover_servers_payload(project_folders) except Exception as e: rich.print(f"[yellow]Warning:[/yellow] Could not discover MCP servers: {e}") return False diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh index 873dd9d0..8cec473b 100755 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.sh +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover +exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover --hook-with-cwd-payload-stdin diff --git a/src/agent_scan/pipelines.py b/src/agent_scan/pipelines.py index 6a697be0..c2a0bec2 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -3,7 +3,7 @@ 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.direct_scanner import direct_scan_to_server_config, is_direct_scan @@ -35,6 +35,7 @@ class InspectArgs(BaseModel): paths: list[str] all_users: bool = False scan_skills: bool = False + project_folders: list[str] = Field(default_factory=list) class AnalyzeArgs(BaseModel): @@ -85,6 +86,18 @@ async def discover_clients_to_inspect( ) ) else: + project_folders: list[Path] = [] + seen_project_folders: set[Path] = set() + for raw_path in inspect_args.project_folders: + project_path = Path(raw_path).expanduser().resolve() + if project_path in seen_project_folders: + continue + seen_project_folders.add(project_path) + if not project_path.exists(): + logger.warning("Skipping non-existent project folder: %s", project_path) + continue + project_folders.append(project_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) @@ -95,7 +108,7 @@ 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, project_folders=project_folders): try: cti = discoverer.discover() except Exception: diff --git a/tests/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index c4915bf6..1deb1f1d 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -8845,3 +8845,191 @@ def flaky_exists(self, *args, **kwargs): assert result is not None assert result.endswith("/.opencode") + + +# --- Explicit project-folder injection --- + + +def test_all_project_folders_puts_agent_roots_before_explicit_roots(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._all_project_folders() == [recorded, explicit] + + +def test_explicit_project_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()}": {{}}}}}}') + + paths = ClaudeCodeDiscoverer(tmp_path, [project])._project_paths_with_ancestors() + + assert paths.count(project) == 1 + assert project.parent in paths + assert tmp_path in paths + + +def test_claude_code_discovers_servers_and_skills_from_explicit_project_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_codex_discovers_servers_and_skills_from_explicit_project(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_explicit_project_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_explicit_project_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_explicit_project_folders(tmp_path): + from agent_scan.agents import ClaudeCodeDiscoverer, find_discoverers + + (tmp_path / ".claude").mkdir() + project = tmp_path / "checkout" + + found = find_discoverers(tmp_path, project_folders=[project]) + + claude = next(discoverer for discoverer in found if isinstance(discoverer, ClaudeCodeDiscoverer)) + assert claude.extra_project_folders == [project] + + +@pytest.mark.asyncio +async def test_pipeline_merges_explicit_project_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, project_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 +async def test_pipeline_skips_missing_explicit_project_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=[], project_folders=[str(missing)])) + + assert str(missing) in caplog.text + assert "Skipping" in caplog.text + + +@pytest.mark.asyncio +async def test_pipeline_explicit_paths_ignore_project_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)], + project_folders=[str(project)], + ) + assert inspect_args.project_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() diff --git a/tests/unit/test_cli_interactivity.py b/tests/unit/test_cli_interactivity.py index e0d44e6d..c974a49d 100644 --- a/tests/unit/test_cli_interactivity.py +++ b/tests/unit/test_cli_interactivity.py @@ -2,6 +2,7 @@ str2bool, and the consent / stream_stderr wiring in run_scan.""" from argparse import Namespace +from pathlib import Path from typing import ClassVar from unittest.mock import AsyncMock, patch @@ -1465,3 +1466,40 @@ async def test_evo_dangerous_alone_overrides_stdio_skip(self): mock_consent.assert_not_called() assert mock_pipeline.call_args.kwargs["do_stdio_handshake"] is True + + +@pytest.mark.asyncio +async def test_run_scan_expands_project_folder_user_home_before_discovery(): + args = TestRunScanConsentAndStreamStderrWiring._scan_args( + project_folders=["~/repo"], + dangerously_run_mcp_servers=True, + ) + discover = AsyncMock(return_value=([], [], [])) + + with ( + patch("agent_scan.cli.discover_clients_to_inspect", discover), + patch("agent_scan.cli.inspect_analyze_push_pipeline", new_callable=AsyncMock, return_value=[]), + ): + await run_scan(args, mode="scan") + + inspect_args = discover.await_args.args[0] + assert inspect_args.project_folders == [str(Path("~/repo").expanduser())] + + +@pytest.mark.asyncio +async def test_run_scan_warns_when_files_and_project_folders_are_combined(capsys): + args = TestRunScanConsentAndStreamStderrWiring._scan_args( + files=["/tmp/config.json"], + project_folders=["/tmp/repo"], + dangerously_run_mcp_servers=True, + ) + + with ( + patch("agent_scan.cli.discover_clients_to_inspect", new_callable=AsyncMock, return_value=([], [], [])), + patch("agent_scan.cli.inspect_analyze_push_pipeline", new_callable=AsyncMock, return_value=[]), + ): + await run_scan(args, mode="scan") + + output = capsys.readouterr().out + assert "--project-folder" in output + assert "ignored" in output.lower() diff --git a/tests/unit/test_cli_parsing.py b/tests/unit/test_cli_parsing.py index eb1d6659..aac8e111 100644 --- a/tests/unit/test_cli_parsing.py +++ b/tests/unit/test_cli_parsing.py @@ -1,13 +1,39 @@ """Tests for CLI argument parsing, especially multiple control servers.""" +import argparse from unittest.mock import AsyncMock, patch import pytest -from agent_scan.cli import MissingIdentifierError, parse_control_servers, warn_deprecated_control_flags +from agent_scan.cli import ( + MissingIdentifierError, + add_common_arguments, + parse_control_servers, + setup_scan_parser, + warn_deprecated_control_flags, +) from agent_scan.models import ControlServer, InspectedPath +def test_scan_project_folder_is_repeatable(): + parser = argparse.ArgumentParser() + setup_scan_parser(parser) + + args = parser.parse_args(["--project-folder", "/repo/one", "--project-folder", "/repo/two"]) + + assert args.project_folders == ["/repo/one", "/repo/two"] + + +def test_inspect_project_folder_is_repeatable(): + parser = argparse.ArgumentParser() + add_common_arguments(parser) + parser.add_argument("files", nargs="*", default=[]) + + args = parser.parse_args(["--project-folder", "/repo/one", "--project-folder", "/repo/two"]) + + assert args.project_folders == ["/repo/one", "/repo/two"] + + class TestControlServerParsing: """Test suite for parsing multiple control servers with individual options.""" diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index e78b2307..b00e8d00 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -415,7 +415,8 @@ def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_pat discover_script = main_script.with_name("snyk-agent-guard-discover.sh") assert discover_script.read_text() == ( - '#!/usr/bin/env bash\nset -euo pipefail\nexec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover\n' + '#!/usr/bin/env bash\nset -euo pipefail\nexec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover ' + "--hook-with-cwd-payload-stdin\n" ) assert os.access(discover_script, os.X_OK) @@ -2530,6 +2531,16 @@ def test_uses_current_user_server_only_discovery(self): assert args.scan_skills is False assert result == guard_module._servers_discovered_entries(clients) + def test_threads_explicit_project_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.project_folders == ["/repo/one", "/repo/two"] + assert result == [] + class TestInvokeHookScript: def test_posix_invocation_sets_machine_id(self, monkeypatch): @@ -2723,12 +2734,46 @@ def test_parses_url_and_file(self, monkeypatch): assert args.url == "https://hooks.example" assert args.file == "/tmp/settings.json" + def test_parses_repeatable_project_folders_and_hook_stdin_flag(self, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr( + sys, + "argv", + [ + "agent-scan", + "guard", + "discover", + "--project-folder", + "/repo/one", + "--project-folder", + "/repo/two", + "--hook-with-cwd-payload-stdin", + ], + ) + 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.project_folders == ["/repo/one", "/repo/two"] + assert args.hook_with_cwd_payload_stdin is True + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only Claude discovery hook") class TestRunDiscover: @staticmethod - def _args(config: Path, url=None): - return SimpleNamespace(guard_command="discover", url=url, file=str(config)) + def _args(config: Path, url=None, **overrides): + values = { + "guard_command": "discover", + "url": url, + "file": str(config), + "project_folders": [], + "hook_with_cwd_payload_stdin": False, + } + values.update(overrides) + return SimpleNamespace(**values) @staticmethod def _write_forwarder(config: Path): @@ -2802,6 +2847,83 @@ def test_missing_forwarding_script_returns_one(self, tmp_path, monkeypatch): assert result == 1 run.assert_not_called() + def test_forwards_explicit_project_folders_to_discovery(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + self._write_forwarder(config) + monkeypatch.setenv("PUSH_KEY", "env-pk") + discover = MagicMock(return_value=[]) + with ( + patch(f"{_G}._discover_servers_payload", discover), + patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, project_folders=["/repo/one", "/repo/two"])) + + assert result == 0 + discover.assert_called_once_with(["/repo/one", "/repo/two"]) + + def test_hook_stdin_appends_nonempty_cwd(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + self._write_forwarder(config) + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + 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("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard( + self._args( + config, + project_folders=["/explicit/project"], + hook_with_cwd_payload_stdin=True, + ) + ) + + assert result == 0 + stdin.read.assert_called_once_with(1024 * 1024) + discover.assert_called_once_with(["/explicit/project", "/session/project"]) + + def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + self._write_forwarder(config) + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + stdin.read.return_value = "not-json" + discover = MagicMock(return_value=[]) + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", discover), + patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard( + self._args(config, project_folders=["/explicit/project"], hook_with_cwd_payload_stdin=True) + ) + + assert result == 0 + discover.assert_called_once_with(["/explicit/project"]) + + def test_stdin_is_never_read_without_hook_flag(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + self._write_forwarder(config) + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + stdin.read.side_effect = AssertionError("stdin must not be read") + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config)) + + assert result == 0 + stdin.read.assert_not_called() + class TestRunInstallSendsServersDiscovered: @staticmethod From ce243d9dfa6ba428d6fcc180813e074b0e4f0898 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 15:22:56 +0200 Subject: [PATCH 09/58] feat: configure hook project folder payload key --- src/agent_scan/cli.py | 12 +++++- src/agent_scan/guard.py | 11 +++-- .../hooks/snyk-agent-guard-discover.sh | 2 +- tests/unit/test_guard.py | 43 ++++++++++++++++++- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index 6be82f9e..ec94e9f8 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -1024,13 +1024,23 @@ def main(): metavar="FOLDER", help="Additional project folder to include in MCP server discovery (repeatable)", ) + guard_discover_parser.add_argument( + "--hook-project-folder-payload-key", + type=str, + default=None, + metavar="KEY", + help=( + "Read the hook JSON payload from stdin and include the non-empty string at KEY as a project folder " + "(Claude Code uses cwd)" + ), + ) guard_discover_parser.add_argument( "--hook-with-cwd-payload-stdin", action="store_true", default=False, help=( "Read the Claude Code hook JSON payload from stdin and include its cwd as a project folder " - "(used by the SessionStart hook)" + "(deprecated compatibility alias for --hook-project-folder-payload-key cwd)" ), ) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index f55ddf93..ad3a8a29 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -313,12 +313,15 @@ def _run_discover(args) -> int: return 1 project_folders = list(getattr(args, "project_folders", None) or []) - if getattr(args, "hook_with_cwd_payload_stdin", False): + project_folder_payload_key = getattr(args, "hook_project_folder_payload_key", None) + if not project_folder_payload_key and getattr(args, "hook_with_cwd_payload_stdin", False): + project_folder_payload_key = "cwd" + if project_folder_payload_key: try: hook_payload = json.loads(sys.stdin.read(1024 * 1024)) - cwd = hook_payload.get("cwd") if isinstance(hook_payload, dict) else None - if isinstance(cwd, str) and cwd: - project_folders.append(cwd) + project_folder = hook_payload.get(project_folder_payload_key) if isinstance(hook_payload, dict) else None + if isinstance(project_folder, str) and project_folder: + project_folders.append(project_folder) except Exception: pass diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh index 8cec473b..628c2be1 100755 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.sh +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover --hook-with-cwd-payload-stdin +exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover --hook-project-folder-payload-key cwd diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index b00e8d00..3037459b 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -416,7 +416,7 @@ def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_pat assert discover_script.read_text() == ( '#!/usr/bin/env bash\nset -euo pipefail\nexec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover ' - "--hook-with-cwd-payload-stdin\n" + "--hook-project-folder-payload-key cwd\n" ) assert os.access(discover_script, os.X_OK) @@ -2760,6 +2760,27 @@ def test_parses_repeatable_project_folders_and_hook_stdin_flag(self, monkeypatch assert args.project_folders == ["/repo/one", "/repo/two"] assert args.hook_with_cwd_payload_stdin is True + def test_parses_configurable_hook_project_folder_payload_key(self, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr( + sys, + "argv", + [ + "agent-scan", + "guard", + "discover", + "--hook-project-folder-payload-key", + "workspaceRoot", + ], + ) + 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].hook_project_folder_payload_key == "workspaceRoot" + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only Claude discovery hook") class TestRunDiscover: @@ -2771,6 +2792,7 @@ def _args(config: Path, url=None, **overrides): "file": str(config), "project_folders": [], "hook_with_cwd_payload_stdin": False, + "hook_project_folder_payload_key": None, } values.update(overrides) return SimpleNamespace(**values) @@ -2887,6 +2909,25 @@ def test_hook_stdin_appends_nonempty_cwd(self, tmp_path, monkeypatch): stdin.read.assert_called_once_with(1024 * 1024) discover.assert_called_once_with(["/explicit/project", "/session/project"]) + def test_hook_stdin_uses_configurable_project_folder_payload_key(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + self._write_forwarder(config) + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + stdin.read.return_value = '{"cwd":"/wrong/project","workspaceRoot":"/session/project"}' + discover = MagicMock(return_value=[]) + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", discover), + patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, hook_project_folder_payload_key="workspaceRoot")) + + assert result == 0 + stdin.read.assert_called_once_with(1024 * 1024) + discover.assert_called_once_with(["/session/project"]) + def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" self._write_forwarder(config) From 665ae86ef61cd5cbedc8bc496713fff1efbea0f0 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 15:30:14 +0200 Subject: [PATCH 10/58] refactor: remove Claude-specific hook payload flag --- src/agent_scan/cli.py | 10 ---------- src/agent_scan/guard.py | 2 -- tests/unit/test_guard.py | 15 ++++++++------- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index ec94e9f8..f8cbdb24 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -1034,16 +1034,6 @@ def main(): "(Claude Code uses cwd)" ), ) - guard_discover_parser.add_argument( - "--hook-with-cwd-payload-stdin", - action="store_true", - default=False, - help=( - "Read the Claude Code hook JSON payload from stdin and include its cwd as a project folder " - "(deprecated compatibility alias for --hook-project-folder-payload-key cwd)" - ), - ) - guard_uninstall_parser = guard_subparsers.add_parser( "uninstall", allow_abbrev=False, diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index ad3a8a29..2c878c87 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -314,8 +314,6 @@ def _run_discover(args) -> int: project_folders = list(getattr(args, "project_folders", None) or []) project_folder_payload_key = getattr(args, "hook_project_folder_payload_key", None) - if not project_folder_payload_key and getattr(args, "hook_with_cwd_payload_stdin", False): - project_folder_payload_key = "cwd" if project_folder_payload_key: try: hook_payload = json.loads(sys.stdin.read(1024 * 1024)) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 3037459b..48eab9c0 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -2734,7 +2734,7 @@ def test_parses_url_and_file(self, monkeypatch): assert args.url == "https://hooks.example" assert args.file == "/tmp/settings.json" - def test_parses_repeatable_project_folders_and_hook_stdin_flag(self, monkeypatch): + def test_parses_repeatable_project_folders(self, monkeypatch): from agent_scan import cli monkeypatch.setattr( @@ -2748,7 +2748,6 @@ def test_parses_repeatable_project_folders_and_hook_stdin_flag(self, monkeypatch "/repo/one", "--project-folder", "/repo/two", - "--hook-with-cwd-payload-stdin", ], ) with patch(f"{_G}.run_guard", return_value=0) as run: @@ -2758,7 +2757,6 @@ def test_parses_repeatable_project_folders_and_hook_stdin_flag(self, monkeypatch assert exc.value.code == 0 args = run.call_args.args[0] assert args.project_folders == ["/repo/one", "/repo/two"] - assert args.hook_with_cwd_payload_stdin is True def test_parses_configurable_hook_project_folder_payload_key(self, monkeypatch): from agent_scan import cli @@ -2791,7 +2789,6 @@ def _args(config: Path, url=None, **overrides): "url": url, "file": str(config), "project_folders": [], - "hook_with_cwd_payload_stdin": False, "hook_project_folder_payload_key": None, } values.update(overrides) @@ -2884,7 +2881,7 @@ def test_forwards_explicit_project_folders_to_discovery(self, tmp_path, monkeypa assert result == 0 discover.assert_called_once_with(["/repo/one", "/repo/two"]) - def test_hook_stdin_appends_nonempty_cwd(self, tmp_path, monkeypatch): + def test_hook_stdin_appends_cwd_with_configured_key(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") @@ -2901,7 +2898,7 @@ def test_hook_stdin_appends_nonempty_cwd(self, tmp_path, monkeypatch): self._args( config, project_folders=["/explicit/project"], - hook_with_cwd_payload_stdin=True, + hook_project_folder_payload_key="cwd", ) ) @@ -2942,7 +2939,11 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): patch(f"{_G}.rich"), ): result = guard_module.run_guard( - self._args(config, project_folders=["/explicit/project"], hook_with_cwd_payload_stdin=True) + self._args( + config, + project_folders=["/explicit/project"], + hook_project_folder_payload_key="cwd", + ) ) assert result == 0 From 02cb89c695a1ce5eec34bf8fdd42fc2ea974b84a Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 15:34:16 +0200 Subject: [PATCH 11/58] refactor: keep project roots internal to hook discovery --- src/agent_scan/cli.py | 21 ------------- src/agent_scan/guard.py | 2 +- tests/unit/test_cli_interactivity.py | 38 ----------------------- tests/unit/test_cli_parsing.py | 28 +---------------- tests/unit/test_guard.py | 46 ++-------------------------- 5 files changed, 4 insertions(+), 131 deletions(-) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index f8cbdb24..b2a0208e 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -14,7 +14,6 @@ import os import sys from dataclasses import dataclass -from pathlib import Path import psutil import rich @@ -480,14 +479,6 @@ def apply_config_file(parser: argparse.ArgumentParser, args: argparse.Namespace, def add_common_arguments(parser): """Add arguments that are common to multiple commands.""" - parser.add_argument( - "--project-folder", - action="append", - dest="project_folders", - default=[], - metavar="FOLDER", - help="Additional project folder to include in MCP server and skills discovery (repeatable)", - ) parser.add_argument( "--storage-file", type=str, @@ -1016,14 +1007,6 @@ def main(): default=None, help="Override the Claude settings file path (default: ~/.claude/settings.json)", ) - guard_discover_parser.add_argument( - "--project-folder", - action="append", - dest="project_folders", - default=[], - metavar="FOLDER", - help="Additional project folder to include in MCP server discovery (repeatable)", - ) guard_discover_parser.add_argument( "--hook-project-folder-payload-key", type=str, @@ -1203,9 +1186,6 @@ async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanRespo server_timeout: int = args.server_timeout if hasattr(args, "server_timeout") else 10 files: list[str] | None = args.files if hasattr(args, "files") else None - project_folders = [str(Path(path).expanduser()) for path in getattr(args, "project_folders", []) or []] - if files and project_folders: - rich.print("[yellow]Warning:[/yellow] --project-folder is ignored when explicit files are provided.") scan_skills: bool = hasattr(args, "skills") and args.skills tokens: list[TokenAndClientInfo] = [] if hasattr(args, "mcp_oauth_tokens_path") and args.mcp_oauth_tokens_path: @@ -1218,7 +1198,6 @@ async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanRespo paths=files, all_users=scan_all_users, scan_skills=scan_skills, - project_folders=project_folders, ) # 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 2c878c87..499bc785 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -312,7 +312,7 @@ def _run_discover(args) -> int: ) return 1 - project_folders = list(getattr(args, "project_folders", None) or []) + project_folders: list[str] = [] project_folder_payload_key = getattr(args, "hook_project_folder_payload_key", None) if project_folder_payload_key: try: diff --git a/tests/unit/test_cli_interactivity.py b/tests/unit/test_cli_interactivity.py index c974a49d..e0d44e6d 100644 --- a/tests/unit/test_cli_interactivity.py +++ b/tests/unit/test_cli_interactivity.py @@ -2,7 +2,6 @@ str2bool, and the consent / stream_stderr wiring in run_scan.""" from argparse import Namespace -from pathlib import Path from typing import ClassVar from unittest.mock import AsyncMock, patch @@ -1466,40 +1465,3 @@ async def test_evo_dangerous_alone_overrides_stdio_skip(self): mock_consent.assert_not_called() assert mock_pipeline.call_args.kwargs["do_stdio_handshake"] is True - - -@pytest.mark.asyncio -async def test_run_scan_expands_project_folder_user_home_before_discovery(): - args = TestRunScanConsentAndStreamStderrWiring._scan_args( - project_folders=["~/repo"], - dangerously_run_mcp_servers=True, - ) - discover = AsyncMock(return_value=([], [], [])) - - with ( - patch("agent_scan.cli.discover_clients_to_inspect", discover), - patch("agent_scan.cli.inspect_analyze_push_pipeline", new_callable=AsyncMock, return_value=[]), - ): - await run_scan(args, mode="scan") - - inspect_args = discover.await_args.args[0] - assert inspect_args.project_folders == [str(Path("~/repo").expanduser())] - - -@pytest.mark.asyncio -async def test_run_scan_warns_when_files_and_project_folders_are_combined(capsys): - args = TestRunScanConsentAndStreamStderrWiring._scan_args( - files=["/tmp/config.json"], - project_folders=["/tmp/repo"], - dangerously_run_mcp_servers=True, - ) - - with ( - patch("agent_scan.cli.discover_clients_to_inspect", new_callable=AsyncMock, return_value=([], [], [])), - patch("agent_scan.cli.inspect_analyze_push_pipeline", new_callable=AsyncMock, return_value=[]), - ): - await run_scan(args, mode="scan") - - output = capsys.readouterr().out - assert "--project-folder" in output - assert "ignored" in output.lower() diff --git a/tests/unit/test_cli_parsing.py b/tests/unit/test_cli_parsing.py index aac8e111..eb1d6659 100644 --- a/tests/unit/test_cli_parsing.py +++ b/tests/unit/test_cli_parsing.py @@ -1,39 +1,13 @@ """Tests for CLI argument parsing, especially multiple control servers.""" -import argparse from unittest.mock import AsyncMock, patch import pytest -from agent_scan.cli import ( - MissingIdentifierError, - add_common_arguments, - parse_control_servers, - setup_scan_parser, - warn_deprecated_control_flags, -) +from agent_scan.cli import MissingIdentifierError, parse_control_servers, warn_deprecated_control_flags from agent_scan.models import ControlServer, InspectedPath -def test_scan_project_folder_is_repeatable(): - parser = argparse.ArgumentParser() - setup_scan_parser(parser) - - args = parser.parse_args(["--project-folder", "/repo/one", "--project-folder", "/repo/two"]) - - assert args.project_folders == ["/repo/one", "/repo/two"] - - -def test_inspect_project_folder_is_repeatable(): - parser = argparse.ArgumentParser() - add_common_arguments(parser) - parser.add_argument("files", nargs="*", default=[]) - - args = parser.parse_args(["--project-folder", "/repo/one", "--project-folder", "/repo/two"]) - - assert args.project_folders == ["/repo/one", "/repo/two"] - - class TestControlServerParsing: """Test suite for parsing multiple control servers with individual options.""" diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 48eab9c0..3a2dba72 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -2734,30 +2734,6 @@ def test_parses_url_and_file(self, monkeypatch): assert args.url == "https://hooks.example" assert args.file == "/tmp/settings.json" - def test_parses_repeatable_project_folders(self, monkeypatch): - from agent_scan import cli - - monkeypatch.setattr( - sys, - "argv", - [ - "agent-scan", - "guard", - "discover", - "--project-folder", - "/repo/one", - "--project-folder", - "/repo/two", - ], - ) - 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.project_folders == ["/repo/one", "/repo/two"] - def test_parses_configurable_hook_project_folder_payload_key(self, monkeypatch): from agent_scan import cli @@ -2788,7 +2764,6 @@ def _args(config: Path, url=None, **overrides): "guard_command": "discover", "url": url, "file": str(config), - "project_folders": [], "hook_project_folder_payload_key": None, } values.update(overrides) @@ -2866,21 +2841,6 @@ def test_missing_forwarding_script_returns_one(self, tmp_path, monkeypatch): assert result == 1 run.assert_not_called() - def test_forwards_explicit_project_folders_to_discovery(self, tmp_path, monkeypatch): - config = tmp_path / "settings.json" - self._write_forwarder(config) - monkeypatch.setenv("PUSH_KEY", "env-pk") - discover = MagicMock(return_value=[]) - with ( - patch(f"{_G}._discover_servers_payload", discover), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), - patch(f"{_G}.rich"), - ): - result = guard_module.run_guard(self._args(config, project_folders=["/repo/one", "/repo/two"])) - - assert result == 0 - discover.assert_called_once_with(["/repo/one", "/repo/two"]) - def test_hook_stdin_appends_cwd_with_configured_key(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" self._write_forwarder(config) @@ -2897,14 +2857,13 @@ def test_hook_stdin_appends_cwd_with_configured_key(self, tmp_path, monkeypatch) result = guard_module.run_guard( self._args( config, - project_folders=["/explicit/project"], hook_project_folder_payload_key="cwd", ) ) assert result == 0 stdin.read.assert_called_once_with(1024 * 1024) - discover.assert_called_once_with(["/explicit/project", "/session/project"]) + discover.assert_called_once_with(["/session/project"]) def test_hook_stdin_uses_configurable_project_folder_payload_key(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" @@ -2941,13 +2900,12 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): result = guard_module.run_guard( self._args( config, - project_folders=["/explicit/project"], hook_project_folder_payload_key="cwd", ) ) assert result == 0 - discover.assert_called_once_with(["/explicit/project"]) + discover.assert_called_once_with([]) def test_stdin_is_never_read_without_hook_flag(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" From 898f4172ae863cc95237e7d9f17dc151787e31f0 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 20 Aug 2026 15:40:34 +0200 Subject: [PATCH 12/58] feat: configure project root field per agent hook --- src/agent_scan/guard.py | 10 +++++ .../hooks/snyk-agent-guard-discover.sh | 2 +- tests/unit/test_guard.py | 38 +++++++++++++++++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 499bc785..92e19c43 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -37,6 +37,11 @@ # --------------------------------------------------------------------------- ALL_CLIENTS = ["claude", "cursor", "codex"] +_HOOK_PROJECT_FOLDER_PAYLOAD_KEYS = { + "claude": "cwd", + "cursor": "workspace_roots", + "codex": "cwd", +} DEFAULT_REMOTE_URL = "https://api.snyk.io" _DETECTION_RE = re.compile( r"PUSH_KEY=.*snyk-agent-guard" @@ -320,6 +325,8 @@ def _run_discover(args) -> int: project_folder = hook_payload.get(project_folder_payload_key) if isinstance(hook_payload, dict) else None if isinstance(project_folder, str) and project_folder: project_folders.append(project_folder) + elif isinstance(project_folder, list): + project_folders.extend(folder for folder in project_folder if isinstance(folder, str) and folder) except Exception: pass @@ -433,6 +440,7 @@ def _install_hooks( dest_path.with_name("snyk-agent-guard-discover.sh"), tenant_id=tenant_id, machine_id=machine_id, + project_folder_payload_key=_HOOK_PROJECT_FOLDER_PAYLOAD_KEYS[client], ) prepared_config, prepared_content, hooks_diff, preserved = _prepare_client_config( client, @@ -1471,6 +1479,7 @@ def _build_discover_hook_command( url: str, script_path: Path, *, + project_folder_payload_key: str, tenant_id: str = "", machine_id: str = "", ) -> str: @@ -1486,6 +1495,7 @@ def _build_discover_hook_command( if agent_scan_bin is not None: parts.append(f"AGENT_SCAN_BIN={_shell_quote(agent_scan_bin)}") parts.append(f"bash {_shell_quote(script_path.as_posix())}") + parts.append(f"--hook-project-folder-payload-key {_shell_quote(project_folder_payload_key)}") return " ".join(parts) diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh index 628c2be1..84f8d2fd 100755 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.sh +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover --hook-project-folder-payload-key cwd +exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover "$@" diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 3a2dba72..3e7feaf6 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -333,6 +333,13 @@ def test_returns_none_when_no_executable_matches(self, tmp_path, monkeypatch): class TestBuildDiscoverHookCommand: + @pytest.mark.parametrize( + "client, expected_key", + [("claude", "cwd"), ("cursor", "workspace_roots"), ("codex", "cwd")], + ) + def test_agent_payload_keys_match_hook_schemas(self, client, expected_key): + assert guard_module._HOOK_PROJECT_FOLDER_PAYLOAD_KEYS[client] == expected_key + def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): with patch(f"{_G}._agent_scan_bin", return_value="/opt/Snyk's bin/snyk-agent-scan"): command = guard_module._build_discover_hook_command( @@ -341,6 +348,7 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): Path("/x/snyk-agent-guard-discover.sh"), tenant_id="tenant", machine_id="machine", + project_folder_payload_key="cwd", ) assert "PUSH_KEY='pk'" in command @@ -348,16 +356,20 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): assert "TENANT_ID='tenant'" in command assert "MACHINE_ID='machine'" in command assert "AGENT_SCAN_BIN='/opt/Snyk'\"'\"'s bin/snyk-agent-scan'" in command - assert command.endswith("bash '/x/snyk-agent-guard-discover.sh'") + assert command.endswith("bash '/x/snyk-agent-guard-discover.sh' --hook-project-folder-payload-key 'cwd'") assert _is_agent_scan_command(command) def test_omits_agent_scan_binary_when_unresolved(self): with patch(f"{_G}._agent_scan_bin", return_value=None): command = guard_module._build_discover_hook_command( - "pk", "https://api.snyk.io", Path("/x/snyk-agent-guard-discover.sh") + "pk", + "https://api.snyk.io", + Path("/x/snyk-agent-guard-discover.sh"), + project_folder_payload_key="workspace_roots", ) assert "AGENT_SCAN_BIN" not in command + assert command.endswith("--hook-project-folder-payload-key 'workspace_roots'") class TestPrepareClaudeDiscoveryHook: @@ -415,8 +427,7 @@ def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_pat discover_script = main_script.with_name("snyk-agent-guard-discover.sh") assert discover_script.read_text() == ( - '#!/usr/bin/env bash\nset -euo pipefail\nexec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover ' - "--hook-project-folder-payload-key cwd\n" + '#!/usr/bin/env bash\nset -euo pipefail\nexec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover "$@"\n' ) assert os.access(discover_script, os.X_OK) @@ -2021,6 +2032,7 @@ def test_claude_builds_and_prepares_async_discovery_hook(self, ctx, tmp_path): assert ctx["build_discover"].call_args.kwargs == { "tenant_id": "tid-1", "machine_id": "machine-42", + "project_folder_payload_key": "cwd", } assert ctx["prep_claude"].call_args.kwargs["discover_command"] == "discover-cmd" @@ -2884,6 +2896,24 @@ def test_hook_stdin_uses_configurable_project_folder_payload_key(self, tmp_path, stdin.read.assert_called_once_with(1024 * 1024) discover.assert_called_once_with(["/session/project"]) + def test_hook_stdin_accepts_workspace_roots_list(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + self._write_forwarder(config) + monkeypatch.setenv("PUSH_KEY", "env-pk") + stdin = MagicMock() + stdin.read.return_value = '{"workspace_roots":["/workspace/one","/workspace/two"]}' + discover = MagicMock(return_value=[]) + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", discover), + patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, hook_project_folder_payload_key="workspace_roots")) + + assert result == 0 + discover.assert_called_once_with(["/workspace/one", "/workspace/two"]) + def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" self._write_forwarder(config) From 885c3fc17c7526a380cec90be56b1e0641950c91 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Fri, 21 Aug 2026 07:26:41 +0200 Subject: [PATCH 13/58] refactor: resolve hook project roots by agent --- src/agent_scan/cli.py | 10 ++++----- src/agent_scan/guard.py | 17 ++++++++------- tests/unit/test_guard.py | 45 ++++++++++++++++++++-------------------- 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index b2a0208e..f2e2b49c 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -1008,14 +1008,12 @@ def main(): help="Override the Claude settings file path (default: ~/.claude/settings.json)", ) guard_discover_parser.add_argument( - "--hook-project-folder-payload-key", + "--hook-agent", type=str, + choices=["claude-code", "cursor", "codex"], default=None, - metavar="KEY", - help=( - "Read the hook JSON payload from stdin and include the non-empty string at KEY as a project folder " - "(Claude Code uses cwd)" - ), + metavar="AGENT", + help=("Read the selected agent's hook JSON payload from stdin and include its project folders in discovery"), ) guard_uninstall_parser = guard_subparsers.add_parser( "uninstall", diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 92e19c43..6e59de25 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -37,8 +37,8 @@ # --------------------------------------------------------------------------- ALL_CLIENTS = ["claude", "cursor", "codex"] -_HOOK_PROJECT_FOLDER_PAYLOAD_KEYS = { - "claude": "cwd", +_HOOK_AGENT_PROJECT_FOLDER_FIELDS = { + "claude-code": "cwd", "cursor": "workspace_roots", "codex": "cwd", } @@ -318,11 +318,12 @@ def _run_discover(args) -> int: return 1 project_folders: list[str] = [] - project_folder_payload_key = getattr(args, "hook_project_folder_payload_key", None) - if project_folder_payload_key: + hook_agent = getattr(args, "hook_agent", None) + project_folder_payload_field = _HOOK_AGENT_PROJECT_FOLDER_FIELDS.get(hook_agent) if hook_agent else None + if project_folder_payload_field: try: hook_payload = json.loads(sys.stdin.read(1024 * 1024)) - project_folder = hook_payload.get(project_folder_payload_key) if isinstance(hook_payload, dict) else None + project_folder = hook_payload.get(project_folder_payload_field) if isinstance(hook_payload, dict) else None if isinstance(project_folder, str) and project_folder: project_folders.append(project_folder) elif isinstance(project_folder, list): @@ -440,7 +441,7 @@ def _install_hooks( dest_path.with_name("snyk-agent-guard-discover.sh"), tenant_id=tenant_id, machine_id=machine_id, - project_folder_payload_key=_HOOK_PROJECT_FOLDER_PAYLOAD_KEYS[client], + hook_agent=hook_client, ) prepared_config, prepared_content, hooks_diff, preserved = _prepare_client_config( client, @@ -1479,7 +1480,7 @@ def _build_discover_hook_command( url: str, script_path: Path, *, - project_folder_payload_key: str, + hook_agent: str, tenant_id: str = "", machine_id: str = "", ) -> str: @@ -1495,7 +1496,7 @@ def _build_discover_hook_command( if agent_scan_bin is not None: parts.append(f"AGENT_SCAN_BIN={_shell_quote(agent_scan_bin)}") parts.append(f"bash {_shell_quote(script_path.as_posix())}") - parts.append(f"--hook-project-folder-payload-key {_shell_quote(project_folder_payload_key)}") + parts.append(f"--hook-agent {_shell_quote(hook_agent)}") return " ".join(parts) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 3e7feaf6..2a0fd3d5 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -334,11 +334,11 @@ def test_returns_none_when_no_executable_matches(self, tmp_path, monkeypatch): class TestBuildDiscoverHookCommand: @pytest.mark.parametrize( - "client, expected_key", - [("claude", "cwd"), ("cursor", "workspace_roots"), ("codex", "cwd")], + "agent, expected_field", + [("claude-code", "cwd"), ("cursor", "workspace_roots"), ("codex", "cwd")], ) - def test_agent_payload_keys_match_hook_schemas(self, client, expected_key): - assert guard_module._HOOK_PROJECT_FOLDER_PAYLOAD_KEYS[client] == expected_key + def test_agent_payload_fields_match_hook_schemas(self, agent, expected_field): + assert guard_module._HOOK_AGENT_PROJECT_FOLDER_FIELDS[agent] == expected_field def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): with patch(f"{_G}._agent_scan_bin", return_value="/opt/Snyk's bin/snyk-agent-scan"): @@ -348,7 +348,7 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): Path("/x/snyk-agent-guard-discover.sh"), tenant_id="tenant", machine_id="machine", - project_folder_payload_key="cwd", + hook_agent="claude-code", ) assert "PUSH_KEY='pk'" in command @@ -356,7 +356,7 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): assert "TENANT_ID='tenant'" in command assert "MACHINE_ID='machine'" in command assert "AGENT_SCAN_BIN='/opt/Snyk'\"'\"'s bin/snyk-agent-scan'" in command - assert command.endswith("bash '/x/snyk-agent-guard-discover.sh' --hook-project-folder-payload-key 'cwd'") + assert command.endswith("bash '/x/snyk-agent-guard-discover.sh' --hook-agent 'claude-code'") assert _is_agent_scan_command(command) def test_omits_agent_scan_binary_when_unresolved(self): @@ -365,11 +365,11 @@ def test_omits_agent_scan_binary_when_unresolved(self): "pk", "https://api.snyk.io", Path("/x/snyk-agent-guard-discover.sh"), - project_folder_payload_key="workspace_roots", + hook_agent="cursor", ) assert "AGENT_SCAN_BIN" not in command - assert command.endswith("--hook-project-folder-payload-key 'workspace_roots'") + assert command.endswith("--hook-agent 'cursor'") class TestPrepareClaudeDiscoveryHook: @@ -2032,7 +2032,7 @@ def test_claude_builds_and_prepares_async_discovery_hook(self, ctx, tmp_path): assert ctx["build_discover"].call_args.kwargs == { "tenant_id": "tid-1", "machine_id": "machine-42", - "project_folder_payload_key": "cwd", + "hook_agent": "claude-code", } assert ctx["prep_claude"].call_args.kwargs["discover_command"] == "discover-cmd" @@ -2746,7 +2746,8 @@ def test_parses_url_and_file(self, monkeypatch): assert args.url == "https://hooks.example" assert args.file == "/tmp/settings.json" - def test_parses_configurable_hook_project_folder_payload_key(self, monkeypatch): + @pytest.mark.parametrize("agent", ["claude-code", "cursor", "codex"]) + def test_parses_hook_agent(self, agent, monkeypatch): from agent_scan import cli monkeypatch.setattr( @@ -2756,8 +2757,8 @@ def test_parses_configurable_hook_project_folder_payload_key(self, monkeypatch): "agent-scan", "guard", "discover", - "--hook-project-folder-payload-key", - "workspaceRoot", + "--hook-agent", + agent, ], ) with patch(f"{_G}.run_guard", return_value=0) as run: @@ -2765,7 +2766,7 @@ def test_parses_configurable_hook_project_folder_payload_key(self, monkeypatch): cli.main() assert exc.value.code == 0 - assert run.call_args.args[0].hook_project_folder_payload_key == "workspaceRoot" + assert run.call_args.args[0].hook_agent == agent @pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only Claude discovery hook") @@ -2776,7 +2777,7 @@ def _args(config: Path, url=None, **overrides): "guard_command": "discover", "url": url, "file": str(config), - "hook_project_folder_payload_key": None, + "hook_agent": None, } values.update(overrides) return SimpleNamespace(**values) @@ -2853,7 +2854,7 @@ def test_missing_forwarding_script_returns_one(self, tmp_path, monkeypatch): assert result == 1 run.assert_not_called() - def test_hook_stdin_appends_cwd_with_configured_key(self, tmp_path, monkeypatch): + def test_hook_stdin_reads_cwd_for_claude_code(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") @@ -2869,7 +2870,7 @@ def test_hook_stdin_appends_cwd_with_configured_key(self, tmp_path, monkeypatch) result = guard_module.run_guard( self._args( config, - hook_project_folder_payload_key="cwd", + hook_agent="claude-code", ) ) @@ -2877,12 +2878,12 @@ def test_hook_stdin_appends_cwd_with_configured_key(self, tmp_path, monkeypatch) stdin.read.assert_called_once_with(1024 * 1024) discover.assert_called_once_with(["/session/project"]) - def test_hook_stdin_uses_configurable_project_folder_payload_key(self, tmp_path, monkeypatch): + def test_hook_stdin_reads_cwd_for_codex(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") stdin = MagicMock() - stdin.read.return_value = '{"cwd":"/wrong/project","workspaceRoot":"/session/project"}' + stdin.read.return_value = '{"cwd":"/session/project","workspace_roots":["/wrong/project"]}' discover = MagicMock(return_value=[]) with ( patch.object(sys, "stdin", stdin), @@ -2890,7 +2891,7 @@ def test_hook_stdin_uses_configurable_project_folder_payload_key(self, tmp_path, patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), patch(f"{_G}.rich"), ): - result = guard_module.run_guard(self._args(config, hook_project_folder_payload_key="workspaceRoot")) + result = guard_module.run_guard(self._args(config, hook_agent="codex")) assert result == 0 stdin.read.assert_called_once_with(1024 * 1024) @@ -2909,7 +2910,7 @@ def test_hook_stdin_accepts_workspace_roots_list(self, tmp_path, monkeypatch): patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), patch(f"{_G}.rich"), ): - result = guard_module.run_guard(self._args(config, hook_project_folder_payload_key="workspace_roots")) + result = guard_module.run_guard(self._args(config, hook_agent="cursor")) assert result == 0 discover.assert_called_once_with(["/workspace/one", "/workspace/two"]) @@ -2930,14 +2931,14 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): result = guard_module.run_guard( self._args( config, - hook_project_folder_payload_key="cwd", + hook_agent="claude-code", ) ) assert result == 0 discover.assert_called_once_with([]) - def test_stdin_is_never_read_without_hook_flag(self, tmp_path, monkeypatch): + def test_stdin_is_never_read_without_hook_agent(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") From cbb1e6fdba77fc572e8252f50fcca3f88e0893d9 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Fri, 21 Aug 2026 08:26:20 +0200 Subject: [PATCH 14/58] refactor: reuse hook client for project roots --- src/agent_scan/cli.py | 4 ++-- src/agent_scan/guard.py | 14 +++++++------- tests/unit/test_guard.py | 42 +++++++++++++++++++++++----------------- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index f2e2b49c..e63b45c6 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -1008,11 +1008,11 @@ def main(): help="Override the Claude settings file path (default: ~/.claude/settings.json)", ) guard_discover_parser.add_argument( - "--hook-agent", + "--client", type=str, choices=["claude-code", "cursor", "codex"], default=None, - metavar="AGENT", + metavar="CLIENT", help=("Read the selected agent's hook JSON payload from stdin and include its project folders in discovery"), ) guard_uninstall_parser = guard_subparsers.add_parser( diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 6e59de25..9a833096 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -37,7 +37,7 @@ # --------------------------------------------------------------------------- ALL_CLIENTS = ["claude", "cursor", "codex"] -_HOOK_AGENT_PROJECT_FOLDER_FIELDS = { +_HOOK_CLIENT_PROJECT_FOLDER_FIELDS = { "claude-code": "cwd", "cursor": "workspace_roots", "codex": "cwd", @@ -318,8 +318,8 @@ def _run_discover(args) -> int: return 1 project_folders: list[str] = [] - hook_agent = getattr(args, "hook_agent", None) - project_folder_payload_field = _HOOK_AGENT_PROJECT_FOLDER_FIELDS.get(hook_agent) if hook_agent else None + hook_client = getattr(args, "client", None) + project_folder_payload_field = _HOOK_CLIENT_PROJECT_FOLDER_FIELDS.get(hook_client) if hook_client else None if project_folder_payload_field: try: hook_payload = json.loads(sys.stdin.read(1024 * 1024)) @@ -334,7 +334,7 @@ def _run_discover(args) -> int: success = _send_servers_discovered_event( push_key, url, - "claude-code", + hook_client or "claude-code", script_path, machine_id, event_name="SessionStartServerDiscovery", @@ -441,7 +441,7 @@ def _install_hooks( dest_path.with_name("snyk-agent-guard-discover.sh"), tenant_id=tenant_id, machine_id=machine_id, - hook_agent=hook_client, + hook_client=hook_client, ) prepared_config, prepared_content, hooks_diff, preserved = _prepare_client_config( client, @@ -1480,7 +1480,7 @@ def _build_discover_hook_command( url: str, script_path: Path, *, - hook_agent: str, + hook_client: str, tenant_id: str = "", machine_id: str = "", ) -> str: @@ -1496,7 +1496,7 @@ def _build_discover_hook_command( if agent_scan_bin is not None: parts.append(f"AGENT_SCAN_BIN={_shell_quote(agent_scan_bin)}") parts.append(f"bash {_shell_quote(script_path.as_posix())}") - parts.append(f"--hook-agent {_shell_quote(hook_agent)}") + parts.append(f"--client {_shell_quote(hook_client)}") return " ".join(parts) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 2a0fd3d5..a7a971e9 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -334,11 +334,11 @@ def test_returns_none_when_no_executable_matches(self, tmp_path, monkeypatch): class TestBuildDiscoverHookCommand: @pytest.mark.parametrize( - "agent, expected_field", + "client, expected_field", [("claude-code", "cwd"), ("cursor", "workspace_roots"), ("codex", "cwd")], ) - def test_agent_payload_fields_match_hook_schemas(self, agent, expected_field): - assert guard_module._HOOK_AGENT_PROJECT_FOLDER_FIELDS[agent] == expected_field + def test_client_payload_fields_match_hook_schemas(self, client, expected_field): + assert guard_module._HOOK_CLIENT_PROJECT_FOLDER_FIELDS[client] == expected_field def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): with patch(f"{_G}._agent_scan_bin", return_value="/opt/Snyk's bin/snyk-agent-scan"): @@ -348,7 +348,7 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): Path("/x/snyk-agent-guard-discover.sh"), tenant_id="tenant", machine_id="machine", - hook_agent="claude-code", + hook_client="claude-code", ) assert "PUSH_KEY='pk'" in command @@ -356,7 +356,7 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): assert "TENANT_ID='tenant'" in command assert "MACHINE_ID='machine'" in command assert "AGENT_SCAN_BIN='/opt/Snyk'\"'\"'s bin/snyk-agent-scan'" in command - assert command.endswith("bash '/x/snyk-agent-guard-discover.sh' --hook-agent 'claude-code'") + assert command.endswith("bash '/x/snyk-agent-guard-discover.sh' --client 'claude-code'") assert _is_agent_scan_command(command) def test_omits_agent_scan_binary_when_unresolved(self): @@ -365,11 +365,11 @@ def test_omits_agent_scan_binary_when_unresolved(self): "pk", "https://api.snyk.io", Path("/x/snyk-agent-guard-discover.sh"), - hook_agent="cursor", + hook_client="cursor", ) assert "AGENT_SCAN_BIN" not in command - assert command.endswith("--hook-agent 'cursor'") + assert command.endswith("--client 'cursor'") class TestPrepareClaudeDiscoveryHook: @@ -2032,7 +2032,7 @@ def test_claude_builds_and_prepares_async_discovery_hook(self, ctx, tmp_path): assert ctx["build_discover"].call_args.kwargs == { "tenant_id": "tid-1", "machine_id": "machine-42", - "hook_agent": "claude-code", + "hook_client": "claude-code", } assert ctx["prep_claude"].call_args.kwargs["discover_command"] == "discover-cmd" @@ -2747,7 +2747,7 @@ def test_parses_url_and_file(self, monkeypatch): assert args.file == "/tmp/settings.json" @pytest.mark.parametrize("agent", ["claude-code", "cursor", "codex"]) - def test_parses_hook_agent(self, agent, monkeypatch): + def test_parses_discovery_client(self, agent, monkeypatch): from agent_scan import cli monkeypatch.setattr( @@ -2757,7 +2757,7 @@ def test_parses_hook_agent(self, agent, monkeypatch): "agent-scan", "guard", "discover", - "--hook-agent", + "--client", agent, ], ) @@ -2766,7 +2766,7 @@ def test_parses_hook_agent(self, agent, monkeypatch): cli.main() assert exc.value.code == 0 - assert run.call_args.args[0].hook_agent == agent + assert run.call_args.args[0].client == agent @pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only Claude discovery hook") @@ -2777,7 +2777,7 @@ def _args(config: Path, url=None, **overrides): "guard_command": "discover", "url": url, "file": str(config), - "hook_agent": None, + "client": None, } values.update(overrides) return SimpleNamespace(**values) @@ -2870,7 +2870,7 @@ def test_hook_stdin_reads_cwd_for_claude_code(self, tmp_path, monkeypatch): result = guard_module.run_guard( self._args( config, - hook_agent="claude-code", + client="claude-code", ) ) @@ -2891,7 +2891,7 @@ def test_hook_stdin_reads_cwd_for_codex(self, tmp_path, monkeypatch): patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), patch(f"{_G}.rich"), ): - result = guard_module.run_guard(self._args(config, hook_agent="codex")) + result = guard_module.run_guard(self._args(config, client="codex")) assert result == 0 stdin.read.assert_called_once_with(1024 * 1024) @@ -2907,13 +2907,19 @@ def test_hook_stdin_accepts_workspace_roots_list(self, tmp_path, monkeypatch): with ( patch.object(sys, "stdin", stdin), patch(f"{_G}._discover_servers_payload", discover), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, patch(f"{_G}.rich"), ): - result = guard_module.run_guard(self._args(config, hook_agent="cursor")) + result = guard_module.run_guard(self._args(config, client="cursor")) assert result == 0 discover.assert_called_once_with(["/workspace/one", "/workspace/two"]) + assert run.call_args.args[0] == [ + "bash", + str(config.parent / "hooks" / "snyk-agent-guard.sh"), + "--client", + "cursor", + ] def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" @@ -2931,14 +2937,14 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): result = guard_module.run_guard( self._args( config, - hook_agent="claude-code", + client="claude-code", ) ) assert result == 0 discover.assert_called_once_with([]) - def test_stdin_is_never_read_without_hook_agent(self, tmp_path, monkeypatch): + def test_stdin_is_never_read_without_client(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") From 9e49202417f05fbb2ef0a6afcf423664f3c4822e Mon Sep 17 00:00:00 2001 From: iamcristi Date: Fri, 21 Aug 2026 11:43:15 +0200 Subject: [PATCH 15/58] feat: session-start server discovery for cursor, codex, and windows --- src/agent_scan/cli.py | 6 +- src/agent_scan/guard.py | 113 ++++++-- .../hooks/snyk-agent-guard-discover.ps1 | 41 +++ tests/e2e/test_guard_install.py | 133 +++++++-- tests/unit/test_guard.py | 274 +++++++++++++++++- 5 files changed, 498 insertions(+), 69 deletions(-) create mode 100644 src/agent_scan/hooks/snyk-agent-guard-discover.ps1 diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index e63b45c6..66903986 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -992,7 +992,7 @@ def main(): allow_abbrev=False, help=( "Run MCP server discovery and send a SessionStartServerDiscovery event through the installed hooks " - "(used by the async Claude Code SessionStart hook)" + "(used by the async session-start hooks that guard install configures)" ), ) guard_discover_parser.add_argument( @@ -1005,7 +1005,9 @@ def main(): "--file", type=str, default=None, - help="Override the Claude settings file path (default: ~/.claude/settings.json)", + help=( + "Override the hook config file path used to locate the forwarding script (default: ~/.claude/settings.json)" + ), ) guard_discover_parser.add_argument( "--client", diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 9a833096..8833f4f4 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -309,11 +309,12 @@ def _run_discover(args) -> int: url = getattr(args, "url", None) or os.environ.get("REMOTE_HOOKS_BASE_URL") or DEFAULT_REMOTE_URL machine_id = (os.environ.get("MACHINE_ID", "") or "").strip() config_path = Path(getattr(args, "file", None) or CLAUDE_SETTINGS_PATH) - script_path = config_path.parent / "hooks" / "snyk-agent-guard.sh" + script_name = "snyk-agent-guard.ps1" if IS_WINDOWS else "snyk-agent-guard.sh" + script_path = config_path.parent / "hooks" / script_name if not script_path.exists(): rich.print( f"[bold red]Error:[/bold red] Agent Guard forwarding script not found: {script_path}. " - "Run guard install claude first." + "Run guard install first." ) return 1 @@ -363,12 +364,16 @@ def _prepare_client_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) 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 @@ -434,14 +439,18 @@ def _install_hooks( machine_id=machine_id, ) discover_command = None - if client == "claude" and not IS_WINDOWS: + if not _is_codex_requirements_toml(config_path): + discover_script = dest_path.with_name( + "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" + ) discover_command = _build_discover_hook_command( push_key, url, - dest_path.with_name("snyk-agent-guard-discover.sh"), + discover_script, tenant_id=tenant_id, machine_id=machine_id, hook_client=hook_client, + config_path=config_path, ) prepared_config, prepared_content, hooks_diff, preserved = _prepare_client_config( client, @@ -514,17 +523,14 @@ def _prepare_claude_config( hooks[event] = existing if discover_command: - hooks["SessionStart"].append( - { - "hooks": [ - { - "type": "command", - "command": discover_command, - "async": True, - } - ] - } - ) + 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: @@ -544,7 +550,12 @@ def _write_claude_config(settings: dict, path: Path, preserved: int) -> bool: 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). @@ -563,6 +574,9 @@ def _prepare_cursor_config(command: str, path: Path) -> tuple[dict, dict, int]: existing.append({"command": command}) hooks[event] = existing + if discover_command: + hooks["sessionStart"].append({"command": discover_command}) + for event, entries in filtered.items(): if event not in hooks: hooks[event] = entries @@ -581,7 +595,12 @@ def _write_cursor_config(data: dict, path: Path, preserved: int) -> bool: 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). @@ -600,6 +619,9 @@ def _prepare_codex_config(command: str, path: Path) -> tuple[dict, dict, int]: existing.append({"hooks": [entry]}) hooks[event] = existing + if discover_command: + 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 @@ -1479,11 +1501,22 @@ def _build_discover_hook_command( push_key: str, url: str, script_path: Path, - *, hook_client: str, + *, + config_path: Path, tenant_id: str = "", machine_id: str = "", ) -> str: + if IS_WINDOWS: + return _build_discover_hook_command_powershell( + push_key, + url, + script_path, + hook_client, + config_path=config_path, + tenant_id=tenant_id, + machine_id=machine_id, + ) parts = [ f"PUSH_KEY={_shell_quote(push_key)}", f"REMOTE_HOOKS_BASE_URL={_shell_quote(url)}", @@ -1497,9 +1530,31 @@ def _build_discover_hook_command( parts.append(f"AGENT_SCAN_BIN={_shell_quote(agent_scan_bin)}") parts.append(f"bash {_shell_quote(script_path.as_posix())}") parts.append(f"--client {_shell_quote(hook_client)}") + parts.append(f"--file {_shell_quote(config_path.as_posix())}") return " ".join(parts) +def _build_discover_hook_command_powershell( + push_key: str, + url: str, + script_path: Path, + hook_client: str, + *, + config_path: Path, + tenant_id: str = "", + machine_id: str = "", +) -> str: + command = f"powershell -File '{script_path}' -Client {hook_client} -PushKey '{push_key}' -RemoteUrl '{url}'" + if machine_id: + escaped_machine_id = machine_id.replace("'", "''") + command += f" -MachineId '{escaped_machine_id}'" + command += f" -ConfigFile '{config_path}'" + agent_scan_bin = _agent_scan_bin() + if agent_scan_bin is not None: + command += f" -AgentScanBin '{agent_scan_bin}'" + return command + + def _build_hook_command_powershell( push_key: str, url: str, @@ -1561,16 +1616,16 @@ def _copy_hook_script(config_path: Path) -> tuple[Path, bool, bool, str | None, new_content = source.read_bytes().replace(b"__AGENT_SCAN_VERSION__", version_info.encode()) new_checksum = hashlib.sha256(new_content).hexdigest() + discover_name = "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" + discover_source = hook_pkg.joinpath(discover_name) + discover_dest = dest_dir / discover_name + discover_content = discover_source.read_bytes() discover_updated = False + if not discover_dest.exists() or discover_dest.read_bytes() != discover_content: + discover_dest.write_bytes(discover_content) + rich.print(f"[green]\u2713[/green] Copied hook script to [dim]{discover_dest}[/dim]") + discover_updated = True if not IS_WINDOWS: - discover_name = "snyk-agent-guard-discover.sh" - discover_source = hook_pkg.joinpath(discover_name) - discover_dest = dest_dir / discover_name - discover_content = discover_source.read_bytes() - if not discover_dest.exists() or discover_dest.read_bytes() != discover_content: - discover_dest.write_bytes(discover_content) - rich.print(f"[green]\u2713[/green] Copied hook script to [dim]{discover_dest}[/dim]") - discover_updated = True discover_dest.chmod(discover_dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) if existed and current_checksum == new_checksum: @@ -1585,7 +1640,7 @@ def _copy_hook_script(config_path: Path) -> tuple[Path, bool, bool, str | None, def _remove_hook_script(client: str, config_path: Path) -> None: dest_dir = config_path.parent / "hooks" script_names = ( - ["snyk-agent-guard.ps1"] + ["snyk-agent-guard.ps1", "snyk-agent-guard-discover.ps1"] if IS_WINDOWS else [ "snyk-agent-guard.sh", 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..f596b8ab --- /dev/null +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -0,0 +1,41 @@ +# +# Session-start discovery trampoline for Snyk Agent Guard (Windows). +# Sets the environment expected by `guard discover` and forwards the hook +# payload from stdin. 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]$ConfigFile, + + [Parameter(Mandatory=$false)] + [string]$AgentScanBin +) + +$ErrorActionPreference = "Stop" + +if ($PushKey) { $env:PUSH_KEY = $PushKey } +if ($RemoteUrl) { $env:REMOTE_HOOKS_BASE_URL = $RemoteUrl } +if ($MachineId) { $env:MACHINE_ID = $MachineId } + +$bin = if ($AgentScanBin) { $AgentScanBin } elseif ($env:AGENT_SCAN_BIN) { $env:AGENT_SCAN_BIN } else { "snyk-agent-scan" } + +$arguments = @("guard", "discover", "--client", $Client) +if ($ConfigFile) { $arguments += @("--file", $ConfigFile) } + +$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8, $true) +$payload = $reader.ReadToEnd() +$payload | & $bin @arguments +exit $LASTEXITCODE diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index eb88f5de..60db9c59 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -81,13 +81,16 @@ 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"] - if os.name != "nt": - 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] - assert "snyk-agent-guard-discover.sh" in discovery_groups[0]["hooks"][0]["command"] + 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) in discover_command + assert ("-ConfigFile" if os.name == "nt" else "--file") in discover_command assert [request["body"]["hook_event_name"] for request in _FakeHookServer.requests] == [ "hooksConfigured", "serversDiscovered", @@ -97,26 +100,25 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): assert isinstance(discovered["body"]["servers"], list) assert json.loads(discovered["headers"]["X-User"])["identifier"] == "e2e-machine-id" - if os.name != "nt": - discover_result = subprocess.run( - [*agent_scan_cmd, "guard", "discover", "--file", str(config_file)], - 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) + discover_result = subprocess.run( + [*agent_scan_cmd, "guard", "discover", "--file", str(config_file)], + 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) @pytest.mark.parametrize("agent_scan_cmd", ["uv", "binary"], indirect=True) def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): @@ -143,3 +145,80 @@ 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) in discovery_entries[0]["command"] + + discover_result = subprocess.run( + [*agent_scan_cmd, "guard", "discover", "--client", "cursor", "--file", str(config_file)], + input=json.dumps({"workspace_roots": [str(tmp_path)]}), + capture_output=True, + text=True, + timeout=60, + env={**os.environ, "PUSH_KEY": "test-pk-e2e", "REMOTE_HOOKS_BASE_URL": fake_hook_server}, + ) + 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"] == "session-start-server-discovery" + assert "session_id" not in session_discovery["body"] + assert isinstance(session_discovery["body"]["servers"], list) + + @pytest.mark.parametrize("agent_scan_cmd", ["uv", "binary"], indirect=True) + def test_guard_install_codex(self, agent_scan_cmd, 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, + ], + capture_output=True, + text=True, + timeout=60, + env={**os.environ, "PUSH_KEY": "test-pk-e2e"}, + ) + 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) in discovery_groups[0]["hooks"][0]["command"] + + discover_result = subprocess.run( + [*agent_scan_cmd, "guard", "discover", "--client", "codex", "--file", str(config_file)], + 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}, + ) + 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) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index a7a971e9..963936dd 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -340,15 +340,18 @@ class TestBuildDiscoverHookCommand: def test_client_payload_fields_match_hook_schemas(self, client, expected_field): assert guard_module._HOOK_CLIENT_PROJECT_FOLDER_FIELDS[client] == expected_field - def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): + @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) + def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): + config_path = Path("/x/config with spaces/settings.json") with patch(f"{_G}._agent_scan_bin", return_value="/opt/Snyk's bin/snyk-agent-scan"): command = guard_module._build_discover_hook_command( "pk", "https://api.snyk.io", Path("/x/snyk-agent-guard-discover.sh"), + client, + config_path=config_path, tenant_id="tenant", machine_id="machine", - hook_client="claude-code", ) assert "PUSH_KEY='pk'" in command @@ -356,7 +359,9 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self): assert "TENANT_ID='tenant'" in command assert "MACHINE_ID='machine'" in command assert "AGENT_SCAN_BIN='/opt/Snyk'\"'\"'s bin/snyk-agent-scan'" in command - assert command.endswith("bash '/x/snyk-agent-guard-discover.sh' --client 'claude-code'") + assert command.endswith( + f"bash '/x/snyk-agent-guard-discover.sh' --client '{client}' --file '/x/config with spaces/settings.json'" + ) assert _is_agent_scan_command(command) def test_omits_agent_scan_binary_when_unresolved(self): @@ -365,11 +370,35 @@ def test_omits_agent_scan_binary_when_unresolved(self): "pk", "https://api.snyk.io", Path("/x/snyk-agent-guard-discover.sh"), - hook_client="cursor", + "cursor", + config_path=Path("/x/hooks.json"), ) assert "AGENT_SCAN_BIN" not in command - assert command.endswith("--client 'cursor'") + assert command.endswith("--client 'cursor' --file '/x/hooks.json'") + + @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), + patch(f"{_G}._agent_scan_bin", return_value=r"C:\Program Files\Snyk\snyk-agent-scan.exe"), + ): + command = guard_module._build_discover_hook_command( + "pk", + "https://api.snyk.io", + Path(r"C:\hooks\snyk-agent-guard-discover.ps1"), + client, + config_path=Path(r"C:\config path\hooks.json"), + 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"-ConfigFile 'C:\config path\hooks.json' " + r"-AgentScanBin 'C:\Program Files\Snyk\snyk-agent-scan.exe'" + ) class TestPrepareClaudeDiscoveryHook: @@ -400,6 +429,25 @@ def test_none_preserves_current_hook_shape(self, tmp_path): 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( @@ -418,6 +466,120 @@ def test_reprepare_is_idempotent(self, tmp_path): 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_cursor_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_cursor_config(data, path, preserved) + + _uninstall_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_codex_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_codex_config(data, path, preserved) + + _uninstall_codex(path) + + assert "hooks" not in json.loads(path.read_text()) + + @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): @@ -490,6 +652,43 @@ def test_full_claude_install_shape_then_uninstall_removes_entries_and_scripts(se 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): + main_script, *_ = guard_module._copy_hook_script(config) + + discover_script = main_script.with_name("snyk-agent-guard-discover.ps1") + 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): + main_script, *_ = guard_module._copy_hook_script(config) + discover_script = main_script.with_name("snyk-agent-guard-discover.ps1") + discover_script.unlink() + + _, _, was_updated, *_ = guard_module._copy_hook_script(config) + + assert discover_script.exists() + assert was_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._copy_hook_script(config) + discover_script = main_script.with_name("snyk-agent-guard-discover.ps1") + + 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"]) @@ -2033,20 +2232,38 @@ def test_claude_builds_and_prepares_async_discovery_hook(self, ctx, tmp_path): "tenant_id": "tid-1", "machine_id": "machine-42", "hook_client": "claude-code", + "config_path": tmp_path / "config.json", } assert ctx["prep_claude"].call_args.kwargs["discover_command"] == "discover-cmd" - def test_cursor_does_not_build_discovery_hook(self, ctx, tmp_path): - self._call(tmp_path, client="cursor", hook_client="cursor") + def test_cursor_builds_discovery_hook(self, ctx, tmp_path): + config = self._call(tmp_path, client="cursor", hook_client="cursor") - ctx["build_discover"].assert_not_called() + ctx["build_discover"].assert_called_once() + assert ctx["build_discover"].call_args.kwargs["config_path"] == config + assert ctx["prep_cursor"].call_args.kwargs["discover_command"] == "discover-cmd" - def test_windows_claude_does_not_build_discovery_hook(self, ctx, tmp_path): + 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() + ctx["dest"].with_name.assert_called_once_with("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): + config = self._call(tmp_path, client="codex", hook_client="codex") + + ctx["build_discover"].assert_called_once() + assert ctx["build_discover"].call_args.kwargs["config_path"] == config + assert ctx["prep_codex"].call_args.kwargs["discover_command"] == "discover-cmd" + + def test_codex_managed_does_not_build_discovery_hook(self, ctx, tmp_path): + ctx["is_toml"].return_value = True + + self._call(tmp_path, client="codex", hook_client="codex") + ctx["build_discover"].assert_not_called() - assert ctx["prep_claude"].call_args.kwargs["discover_command"] is None def test_returns_installed_script_path(self, ctx, tmp_path): result = _install_hooks( @@ -2769,8 +2986,12 @@ def test_parses_discovery_client(self, agent, monkeypatch): assert run.call_args.args[0].client == agent -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only Claude discovery hook") class TestRunDiscover: + @pytest.fixture(autouse=True) + def _posix_mode(self): + with patch(f"{_G}.IS_WINDOWS", False): + yield + @staticmethod def _args(config: Path, url=None, **overrides): values = { @@ -2961,6 +3182,37 @@ def test_stdin_is_never_read_without_client(self, tmp_path, monkeypatch): assert result == 0 stdin.read.assert_not_called() + def test_windows_resolves_powershell_forwarder(self, tmp_path, monkeypatch): + config = tmp_path / "hooks.json" + script = config.parent / "hooks" / "snyk-agent-guard.ps1" + script.parent.mkdir(parents=True) + script.write_text("# forwarder\n") + 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("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, client="codex")) + + assert result == 0 + assert run.call_args.args[0] == [ + "powershell", + "-File", + str(script), + "-Client", + "codex", + "-PushKey", + "env-pk", + "-RemoteUrl", + "https://env-hooks.example", + "-MachineId", + "env-machine", + ] + class TestRunInstallSendsServersDiscovered: @staticmethod From 76fb424ff12ae61e76ba4f68351e7b12075f0d17 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Fri, 21 Aug 2026 12:09:16 +0200 Subject: [PATCH 16/58] fix: silence session-start discovery hook output --- src/agent_scan/hooks/snyk-agent-guard-discover.ps1 | 2 +- src/agent_scan/hooks/snyk-agent-guard-discover.sh | 2 +- tests/unit/test_guard.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 index f596b8ab..1e62b148 100644 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -37,5 +37,5 @@ if ($ConfigFile) { $arguments += @("--file", $ConfigFile) } $reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8, $true) $payload = $reader.ReadToEnd() -$payload | & $bin @arguments +$payload | & $bin @arguments *> $null exit $LASTEXITCODE diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh index 84f8d2fd..5f348e72 100755 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.sh +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover "$@" +exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover "$@" >/dev/null 2>&1 diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 963936dd..a82e354e 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -589,7 +589,8 @@ def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_pat discover_script = main_script.with_name("snyk-agent-guard-discover.sh") assert discover_script.read_text() == ( - '#!/usr/bin/env bash\nset -euo pipefail\nexec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover "$@"\n' + "#!/usr/bin/env bash\nset -euo pipefail\n" + 'exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover "$@" >/dev/null 2>&1\n' ) assert os.access(discover_script, os.X_OK) From 4fa2e8054034b58d549e21cab061ca56d91dcfbd Mon Sep 17 00:00:00 2001 From: iamcristi Date: Fri, 21 Aug 2026 15:50:03 +0200 Subject: [PATCH 17/58] fix: forward session id in discovery hook --- src/agent_scan/guard.py | 24 +++++++++++-- tests/e2e/test_guard_install.py | 6 ++-- tests/unit/test_guard.py | 63 ++++++++++++++++++++++++++++++--- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 8833f4f4..05848d43 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -42,6 +42,11 @@ "cursor": "workspace_roots", "codex": "cwd", } +_HOOK_CLIENT_SESSION_FIELDS = { + "claude-code": "session_id", + "cursor": "conversation_id", + "codex": "session_id", +} DEFAULT_REMOTE_URL = "https://api.snyk.io" _DETECTION_RE = re.compile( r"PUSH_KEY=.*snyk-agent-guard" @@ -319,16 +324,29 @@ def _run_discover(args) -> int: return 1 project_folders: list[str] = [] + session_id = "" hook_client = getattr(args, "client", None) project_folder_payload_field = _HOOK_CLIENT_PROJECT_FOLDER_FIELDS.get(hook_client) if hook_client else None - if project_folder_payload_field: + session_payload_field = _HOOK_CLIENT_SESSION_FIELDS.get(hook_client) if hook_client else None + if project_folder_payload_field or session_payload_field: try: hook_payload = json.loads(sys.stdin.read(1024 * 1024)) - project_folder = hook_payload.get(project_folder_payload_field) if isinstance(hook_payload, dict) else None + project_folder = ( + hook_payload.get(project_folder_payload_field) + if isinstance(hook_payload, dict) and project_folder_payload_field + else None + ) if isinstance(project_folder, str) and project_folder: project_folders.append(project_folder) elif isinstance(project_folder, list): project_folders.extend(folder for folder in project_folder if isinstance(folder, str) and folder) + raw_session_id = ( + hook_payload.get(session_payload_field) + if isinstance(hook_payload, dict) and session_payload_field + else None + ) + if isinstance(raw_session_id, str) and raw_session_id: + session_id = raw_session_id except Exception: pass @@ -339,7 +357,7 @@ def _run_discover(args) -> int: script_path, machine_id, event_name="SessionStartServerDiscovery", - session_marker="session-start-server-discovery", + session_marker=session_id or "session-start-server-discovery", project_folders=project_folders, ) return 0 if success else 1 diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index 60db9c59..d745eb51 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -156,7 +156,7 @@ def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): discover_result = subprocess.run( [*agent_scan_cmd, "guard", "discover", "--client", "cursor", "--file", str(config_file)], - input=json.dumps({"workspace_roots": [str(tmp_path)]}), + input=json.dumps({"workspace_roots": [str(tmp_path)], "conversation_id": "e2e-conversation"}), capture_output=True, text=True, timeout=60, @@ -167,7 +167,7 @@ def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): ) session_discovery = _FakeHookServer.requests[-1] assert session_discovery["body"]["hook_event_name"] == "SessionStartServerDiscovery" - assert session_discovery["body"]["conversation_id"] == "session-start-server-discovery" + assert session_discovery["body"]["conversation_id"] == "e2e-conversation" assert "session_id" not in session_discovery["body"] assert isinstance(session_discovery["body"]["servers"], list) @@ -220,5 +220,5 @@ def test_guard_install_codex(self, agent_scan_cmd, tmp_path, fake_hook_server): ) session_discovery = _FakeHookServer.requests[-1] assert session_discovery["body"]["hook_event_name"] == "SessionStartServerDiscovery" - assert session_discovery["body"]["session_id"] == "session-start-server-discovery" + assert session_discovery["body"]["session_id"] == "e2e" assert isinstance(session_discovery["body"]["servers"], list) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index a82e354e..3b01f021 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -3086,7 +3086,7 @@ def test_hook_stdin_reads_cwd_for_claude_code(self, tmp_path, monkeypatch): with ( patch.object(sys, "stdin", stdin), patch(f"{_G}._discover_servers_payload", discover), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, patch(f"{_G}.rich"), ): result = guard_module.run_guard( @@ -3099,18 +3099,21 @@ def test_hook_stdin_reads_cwd_for_claude_code(self, tmp_path, monkeypatch): assert result == 0 stdin.read.assert_called_once_with(1024 * 1024) discover.assert_called_once_with(["/session/project"]) + assert json.loads(run.call_args.kwargs["input"])["session_id"] == "session" def test_hook_stdin_reads_cwd_for_codex(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") stdin = MagicMock() - stdin.read.return_value = '{"cwd":"/session/project","workspace_roots":["/wrong/project"]}' + 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("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, patch(f"{_G}.rich"), ): result = guard_module.run_guard(self._args(config, client="codex")) @@ -3118,13 +3121,16 @@ def test_hook_stdin_reads_cwd_for_codex(self, tmp_path, monkeypatch): assert result == 0 stdin.read.assert_called_once_with(1024 * 1024) discover.assert_called_once_with(["/session/project"]) + assert json.loads(run.call_args.kwargs["input"])["session_id"] == "session" def test_hook_stdin_accepts_workspace_roots_list(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") stdin = MagicMock() - stdin.read.return_value = '{"workspace_roots":["/workspace/one","/workspace/two"]}' + stdin.read.return_value = ( + '{"workspace_roots":["/workspace/one","/workspace/two"],"conversation_id":"conversation"}' + ) discover = MagicMock(return_value=[]) with ( patch.object(sys, "stdin", stdin), @@ -3142,6 +3148,7 @@ def test_hook_stdin_accepts_workspace_roots_list(self, tmp_path, monkeypatch): "--client", "cursor", ] + assert json.loads(run.call_args.kwargs["input"])["conversation_id"] == "conversation" def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" @@ -3153,7 +3160,7 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): with ( patch.object(sys, "stdin", stdin), patch(f"{_G}._discover_servers_payload", discover), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, patch(f"{_G}.rich"), ): result = guard_module.run_guard( @@ -3165,6 +3172,52 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): assert result == 0 discover.assert_called_once_with([]) + assert json.loads(run.call_args.kwargs["input"])["session_id"] == "session-start-server-discovery" + + @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" + self._write_forwarder(config) + monkeypatch.setenv("PUSH_KEY", "env-pk") + hook_payload = {} if session_value is None else {session_field: session_value} + stdin = MagicMock() + stdin.read.return_value = json.dumps(hook_payload) + with ( + patch.object(sys, "stdin", stdin), + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, + patch(f"{_G}.rich"), + ): + result = guard_module.run_guard(self._args(config, client=client)) + + assert result == 0 + event_payload = json.loads(run.call_args.kwargs["input"]) + assert event_payload[event_session_field] == expected_marker def test_stdin_is_never_read_without_client(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" From 5ae0a96fd53cd19498f509a18aec242732f85bc3 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Mon, 24 Aug 2026 11:15:02 +0200 Subject: [PATCH 18/58] feat: report server discovery duration --- src/agent_scan/guard.py | 4 ++++ tests/e2e/test_guard_install.py | 8 ++++++++ tests/unit/test_guard.py | 27 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 05848d43..9f13cadf 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -11,6 +11,7 @@ import shutil import stat import sys +import time from importlib import resources as importlib_resources from pathlib import Path from typing import TYPE_CHECKING @@ -1232,15 +1233,18 @@ def _send_servers_discovered_event( project_folders: list[str] | None = None, ) -> bool: rich.print("[dim]Discovering MCP servers...[/dim]") + started = time.monotonic() try: servers = _discover_servers_payload(project_folders) except Exception as 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, } if hook_client == "claude-code" or hook_client == "codex": payload_dict["session_id"] = session_marker diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index d745eb51..85d27f1e 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -98,6 +98,8 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): 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 assert json.loads(discovered["headers"]["X-User"])["identifier"] == "e2e-machine-id" discover_result = subprocess.run( @@ -119,6 +121,8 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): 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): @@ -170,6 +174,8 @@ def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): 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, tmp_path, fake_hook_server): @@ -222,3 +228,5 @@ def test_guard_install_codex(self, agent_scan_cmd, tmp_path, fake_hook_server): 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_guard.py b/tests/unit/test_guard.py index 3b01f021..0ea6874d 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -2864,6 +2864,8 @@ def test_payload_contract_for_client(self, hook_client, id_key): 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["env"]["MACHINE_ID"] == "machine-42" @@ -2898,6 +2900,28 @@ def fake_run(cmd, *, input, **kwargs): 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_run(cmd, *, input, **kwargs): + captured["payload"] = json.loads(input) + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + + with ( + patch(f"{_G}.IS_WINDOWS", False), + patch(f"{_G}._discover_servers_payload", return_value=[]), + patch("subprocess.run", side_effect=fake_run), + 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", Path("/hook.sh"), "machine-42" + ) + + assert ok is True + assert captured["payload"]["discovery_duration_ms"] == 250 + assert isinstance(captured["payload"]["discovery_duration_ms"], int) + def test_nonzero_exit_warns_and_returns_false(self): completed = subprocess.CompletedProcess([], 2, stdout="", stderr="failed") with ( @@ -3034,6 +3058,9 @@ def fake_run(cmd, *, input, **kwargs): assert result == 0 assert captured["cmd"] == ["bash", str(script), "--client", "claude-code"] + duration = captured["payload"].pop("discovery_duration_ms") + assert isinstance(duration, int) + assert duration >= 0 assert captured["payload"] == { "hook_event_name": "SessionStartServerDiscovery", "servers": [], From 722ed25571cdc1db3ec15daec05859ff02e6051e Mon Sep 17 00:00:00 2001 From: iamcristi Date: Mon, 24 Aug 2026 11:23:28 +0200 Subject: [PATCH 19/58] fix: harden discovery hook delivery --- src/agent_scan/guard.py | 11 +++-- src/agent_scan/hooks/snyk-agent-guard.sh | 4 +- tests/unit/test_guard.py | 56 ++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 9f13cadf..3445bd01 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -448,6 +448,9 @@ def _install_hooks( 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 + discover_script_name = "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" + discover_script_path = config_path.parent / "hooks" / discover_script_name + discover_script_existed = discover_script_path.exists() dest_path, script_existed, script_updated, current_checksum, new_checksum = _copy_hook_script(config_path) command = _build_hook_command( push_key, @@ -459,13 +462,11 @@ def _install_hooks( ) discover_command = None if not _is_codex_requirements_toml(config_path): - discover_script = dest_path.with_name( - "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" - ) + installed_discover_script_path = dest_path.with_name(discover_script_name) discover_command = _build_discover_hook_command( push_key, url, - discover_script, + installed_discover_script_path, tenant_id=tenant_id, machine_id=machine_id, hook_client=hook_client, @@ -496,6 +497,8 @@ def _install_hooks( ): if not 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) diff --git a/src/agent_scan/hooks/snyk-agent-guard.sh b/src/agent_scan/hooks/snyk-agent-guard.sh index cd29f03d..799bfa48 100755 --- a/src/agent_scan/hooks/snyk-agent-guard.sh +++ b/src/agent_scan/hooks/snyk-agent-guard.sh @@ -144,10 +144,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/tests/unit/test_guard.py b/tests/unit/test_guard.py index 0ea6874d..7a7f5055 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -1620,6 +1620,34 @@ 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": "serversDiscovered", + "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, + }, + ) + + 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"}' @@ -2503,6 +2531,34 @@ def test_test_event_failure_cleans_new_script(self, ctx, tmp_path): 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 = tmp_path / "hooks" / "snyk-agent-guard-discover.sh" + + def copy_scripts(_config_path): + discover_script.parent.mkdir(parents=True) + discover_script.write_text("#!/bin/sh\n") + return ctx["dest"], False, True, None, _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 = tmp_path / "hooks" / "snyk-agent-guard-discover.sh" + discover_script.parent.mkdir(parents=True) + discover_script.write_text("existing\n") + ctx["copy"].return_value = (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["test_event"].return_value = False From d1f1edf2575fef24cf6f84f3b2a601a02136bdaf Mon Sep 17 00:00:00 2001 From: iamcristi Date: Mon, 24 Aug 2026 13:00:21 +0200 Subject: [PATCH 20/58] test: make discovery hooks portable on Windows --- tests/unit/test_guard.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 7a7f5055..06abf686 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -343,7 +343,10 @@ def test_client_payload_fields_match_hook_schemas(self, client, expected_field): @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): config_path = Path("/x/config with spaces/settings.json") - with patch(f"{_G}._agent_scan_bin", return_value="/opt/Snyk's bin/snyk-agent-scan"): + with ( + patch(f"{_G}.IS_WINDOWS", False), + patch(f"{_G}._agent_scan_bin", return_value="/opt/Snyk's bin/snyk-agent-scan"), + ): command = guard_module._build_discover_hook_command( "pk", "https://api.snyk.io", @@ -365,7 +368,10 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): assert _is_agent_scan_command(command) def test_omits_agent_scan_binary_when_unresolved(self): - with patch(f"{_G}._agent_scan_bin", return_value=None): + with ( + patch(f"{_G}.IS_WINDOWS", False), + patch(f"{_G}._agent_scan_bin", return_value=None), + ): command = guard_module._build_discover_hook_command( "pk", "https://api.snyk.io", @@ -407,11 +413,12 @@ class TestPrepareClaudeDiscoveryHook: ) def test_adds_separate_async_matcherless_session_start_group(self, tmp_path): - settings, _, _ = _prepare_claude_config( - AGENT_SCAN_CMD, - tmp_path / "settings.json", - discover_command=self.discover_command, - ) + 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 @@ -2532,7 +2539,10 @@ def test_test_event_failure_cleans_new_script(self, ctx, 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 = tmp_path / "hooks" / "snyk-agent-guard-discover.sh" + 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(_config_path): discover_script.parent.mkdir(parents=True) @@ -2548,7 +2558,10 @@ def copy_scripts(_config_path): assert not discover_script.exists() def test_test_event_failure_keeps_existing_discovery_script(self, ctx, tmp_path): - discover_script = tmp_path / "hooks" / "snyk-agent-guard-discover.sh" + 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["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM) From 20efd8f9b3375eb51f685a74b0e393f5313ae97c Mon Sep 17 00:00:00 2001 From: iamcristi Date: Mon, 24 Aug 2026 15:21:43 +0200 Subject: [PATCH 21/58] fix: harden server discovery hooks --- docs/cli-reference.md | 22 +- src/agent_scan/agents/base.py | 15 +- src/agent_scan/cli.py | 41 +- src/agent_scan/guard.py | 217 ++++++++-- .../hooks/snyk-agent-guard-discover.ps1 | 1 + .../hooks/snyk-agent-guard-discover.sh | 9 +- src/agent_scan/pipelines.py | 12 +- tests/unit/test_agent_discovery.py | 42 ++ tests/unit/test_cli_config_file.py | 17 + tests/unit/test_guard.py | 386 ++++++++++++++++-- 10 files changed, 674 insertions(+), 88 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 52e91a73..2c13eac6 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,31 @@ snyk-agent-scan guard snyk-agent-scan guard install {claude,cursor,codex,all} [OPTIONS] ``` +Installation also configures a fire-and-forget session-start hook that reports discovered MCP servers. + | 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`, `--control-identifier ID` | string | — | Non-anonymous machine identifier sent in the `X-User` header's `identifier` field. | | `--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 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. | +| `--file PATH` | string | — | Override the client configuration path used to locate the forwarding script. | +| `--client {claude-code,cursor,codex}` | string | — | Hook client whose payload and endpoint conventions should be used. | + ### `guard uninstall` ```bash @@ -381,6 +398,9 @@ 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` | Non-anonymous machine identifier sent with hook events | +| `AGENT_SCAN_BIN` | Agent Scan executable used by the session-start discovery trampoline | +| `AGENT_SCAN_DISCOVERY_TIMEOUT_SECONDS` | Discovery timeout in seconds (default: `60`) | ## Environment variables diff --git a/src/agent_scan/agents/base.py b/src/agent_scan/agents/base.py index 55b6c640..3e4b619a 100644 --- a/src/agent_scan/agents/base.py +++ b/src/agent_scan/agents/base.py @@ -456,8 +456,19 @@ def _discover_project_folders(self) -> list[Path]: return [] def _all_project_folders(self) -> list[Path]: - """Agent-recorded project roots followed by explicitly supplied roots.""" - return list(dict.fromkeys([*self._discover_project_folders(), *self.extra_project_folders])) + """Return recorded roots then explicit roots, deduped without changing their spelling.""" + result: list[Path] = [] + seen: set[Path] = set() + for folder in (*self._discover_project_folders(), *self.extra_project_folders): + try: + key = folder.resolve() + except OSError: + key = folder + if key in seen: + continue + seen.add(key) + result.append(folder) + return result def _project_paths_with_ancestors(self) -> list[Path]: """Project roots plus every ancestor up to filesystem root, deduplicated. diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index 66903986..7c81b27b 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -227,18 +227,43 @@ def _iter_all_actions(parser: argparse.ArgumentParser): yield action +def _option_value_count(action: argparse.Action) -> int: + """Return how many argv tokens follow an option for the shapes this CLI uses.""" + if action.nargs == 0: + return 0 + if isinstance(action.nargs, int): + return action.nargs + return 1 + + def _iter_active_actions(parser: argparse.ArgumentParser, argv: list[str]): - """Yield actions from the root parser and the subparser path selected by ``argv``.""" + """Yield actions from the parser path selected while consuming ``argv`` like argparse.""" + subparsers: argparse._SubParsersAction | None = None + values_consumed: dict[str, int] = {} for action in parser._actions: - if not isinstance(action, argparse._SubParsersAction): - yield action + if isinstance(action, argparse._SubParsersAction): + subparsers = action continue + yield action + for option in action.option_strings: + values_consumed[option] = _option_value_count(action) - for index, token in enumerate(argv): - subparser = action.choices.get(token) - if subparser is not None: - yield from _iter_active_actions(subparser, argv[index + 1 :]) - break + if subparsers is None: + return + + index = 0 + while index < len(argv): + token = argv[index] + if token == "--": + index += 1 + continue + if token.startswith("-") and token != "-": + index += 1 if "=" in token else 1 + values_consumed.get(token, 0) + continue + subparser = subparsers.choices.get(token) + if subparser is not None: + yield from _iter_active_actions(subparser, argv[index + 1 :]) + return def explicitly_provided_dests(parser: argparse.ArgumentParser, argv: list[str]) -> set[str]: diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 3445bd01..4703a1d6 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -14,7 +14,7 @@ import time from importlib import resources as importlib_resources from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple, TypeVar from urllib.parse import urlparse import rich @@ -29,9 +29,12 @@ from agent_scan.redact import redact_push_keys, redact_push_keys_in_data if TYPE_CHECKING: + from collections.abc import Callable + from agent_scan.models import ClientToInspect IS_WINDOWS = sys.platform == "win32" +_T = TypeVar("_T") # --------------------------------------------------------------------------- # Constants @@ -54,6 +57,8 @@ r"|snyk-agent-guard.*-PushKey\b" ) _PERMISSION_DENIED = "__permission_denied__" +_STDIN_READ_TIMEOUT_SECONDS = 5.0 +_DEFAULT_DISCOVERY_TIMEOUT_SECONDS = 60.0 CLAUDE_SETTINGS_PATH = Path.home() / ".claude" / "settings.json" CURSOR_HOOKS_PATH = Path.home() / ".cursor" / "hooks.json" @@ -306,6 +311,48 @@ def _run_install(args) -> None: _send_servers_discovered_event(push_key, url, *first_installed, machine_id) +def _run_with_timeout(func: Callable[[], _T], timeout: float) -> _T: + """Run ``func`` on a daemon thread; raise ``TimeoutError`` if it outlives ``timeout``.""" + import threading + + 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 _discovery_timeout_seconds() -> float: + try: + value = float(os.environ.get("AGENT_SCAN_DISCOVERY_TIMEOUT_SECONDS", "")) + except ValueError: + return _DEFAULT_DISCOVERY_TIMEOUT_SECONDS + return value if value > 0 else _DEFAULT_DISCOVERY_TIMEOUT_SECONDS + + +def _read_hook_payload() -> str: + """Read hook JSON from stdin without allowing an open stream to hang discovery.""" + 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: @@ -331,7 +378,7 @@ def _run_discover(args) -> int: session_payload_field = _HOOK_CLIENT_SESSION_FIELDS.get(hook_client) if hook_client else None if project_folder_payload_field or session_payload_field: try: - hook_payload = json.loads(sys.stdin.read(1024 * 1024)) + hook_payload = json.loads(_read_hook_payload()) project_folder = ( hook_payload.get(project_folder_payload_field) if isinstance(hook_payload, dict) and project_folder_payload_field @@ -448,10 +495,19 @@ def _install_hooks( 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 + is_codex_requirements = _is_codex_requirements_toml(config_path) discover_script_name = "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" discover_script_path = config_path.parent / "hooks" / discover_script_name discover_script_existed = discover_script_path.exists() - dest_path, script_existed, script_updated, current_checksum, new_checksum = _copy_hook_script(config_path) + ( + dest_path, + script_existed, + script_updated, + current_checksum, + new_checksum, + discover_current_checksum, + discover_new_checksum, + ) = _copy_hook_script(config_path, include_discover=not is_codex_requirements) command = _build_hook_command( push_key, url, @@ -461,7 +517,7 @@ def _install_hooks( machine_id=machine_id, ) discover_command = None - if not _is_codex_requirements_toml(config_path): + if not is_codex_requirements: installed_discover_script_path = dest_path.with_name(discover_script_name) discover_command = _build_discover_hook_command( push_key, @@ -493,6 +549,8 @@ def _install_hooks( push_key_changed=push_key_changed, current_checksum=current_checksum, new_checksum=new_checksum, + discover_current_checksum=discover_current_checksum, + discover_new_checksum=discover_new_checksum, machine_id=machine_id, ): if not script_existed: @@ -545,6 +603,7 @@ def _prepare_claude_config( 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, @@ -597,6 +656,7 @@ def _prepare_cursor_config( 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(): @@ -642,6 +702,7 @@ def _prepare_codex_config( 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(): @@ -1092,21 +1153,41 @@ def _detect_cursor_install(path: Path = CURSOR_HOOKS_PATH) -> dict | None: def _servers_discovered_entries(clients_to_inspect: list[ClientToInspect]) -> list[dict]: - from agent_scan.models import InspectedPath, InspectedServer - from agent_scan.models.api.v20260710 import ScanPathRequest + """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 - entries = [] + 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 not isinstance(discovered, list): + if isinstance(discovered, FileNotFoundConfig | UnknownConfigFormat | CouldNotParseMCPConfig): + config_errors.append(_config_error_to_scan_error(discovered)) continue servers.extend( - InspectedServer(name=name, config_path=config_path, server=server) for name, server in discovered + 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), ) - inspected_path = InspectedPath(client=client.name, path=client.client_path, servers=servers) - entries.append(ScanPathRequest.from_inspected(inspected_path).model_dump(mode="json")) - return entries + ) + return [request.model_dump(mode="json") for request in build_scan_request(inspected_paths).scan_path_requests] def _discover_servers_payload(project_folders: list[str] | None = None) -> list[dict]: @@ -1121,7 +1202,10 @@ def _discover_servers_payload(project_folders: list[str] | None = None) -> list[ paths=[], project_folders=project_folders or [], ) - clients_to_inspect, _, _ = asyncio.run(pipelines.discover_clients_to_inspect(inspect_args)) + 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) @@ -1190,6 +1274,8 @@ 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.""" @@ -1211,6 +1297,10 @@ 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) @@ -1512,13 +1602,15 @@ def _agent_scan_bin() -> str | None: if getattr(sys, "frozen", False): return str(Path(sys.executable).resolve()) + names = ("snyk-agent-scan.exe", "snyk-agent-scan") if IS_WINDOWS else ("snyk-agent-scan",) invoked_path = Path(sys.argv[0]) - if invoked_path.name == "snyk-agent-scan" and invoked_path.is_file() and os.access(invoked_path, os.X_OK): + if invoked_path.name in names and invoked_path.is_file() and os.access(invoked_path, os.X_OK): return str(invoked_path.resolve()) - console_script = Path(sys.executable).parent / "snyk-agent-scan" - if console_script.is_file() and os.access(console_script, os.X_OK): - return str(console_script.resolve()) + for name in names: + console_script = Path(sys.executable).parent / name + if console_script.is_file() and os.access(console_script, os.X_OK): + return str(console_script.resolve()) return None @@ -1569,14 +1661,16 @@ def _build_discover_hook_command_powershell( tenant_id: str = "", machine_id: str = "", ) -> str: - command = f"powershell -File '{script_path}' -Client {hook_client} -PushKey '{push_key}' -RemoteUrl '{url}'" + command = ( + f"powershell -File {_ps_quote(str(script_path))} -Client {hook_client} " + f"-PushKey {_ps_quote(push_key)} -RemoteUrl {_ps_quote(url)}" + ) if machine_id: - escaped_machine_id = machine_id.replace("'", "''") - command += f" -MachineId '{escaped_machine_id}'" - command += f" -ConfigFile '{config_path}'" + command += f" -MachineId {_ps_quote(machine_id)}" + command += f" -ConfigFile {_ps_quote(str(config_path))}" agent_scan_bin = _agent_scan_bin() if agent_scan_bin is not None: - command += f" -AgentScanBin '{agent_scan_bin}'" + command += f" -AgentScanBin {_ps_quote(agent_scan_bin)}" return command @@ -1589,10 +1683,12 @@ def _build_hook_command_powershell( tenant_id: str = "", machine_id: str = "", ) -> str: - command = f"powershell -File '{script_path}' -Client {hook_client} -PushKey '{push_key}' -RemoteUrl '{url}'" + command = ( + f"powershell -File {_ps_quote(str(script_path))} -Client {hook_client} " + f"-PushKey {_ps_quote(push_key)} -RemoteUrl {_ps_quote(url)}" + ) if machine_id: - escaped_machine_id = machine_id.replace("'", "''") - command += f" -MachineId '{escaped_machine_id}'" + command += f" -MachineId {_ps_quote(machine_id)}" return command @@ -1600,6 +1696,11 @@ 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 @@ -1615,13 +1716,21 @@ 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]: +class _HookScripts(NamedTuple): + path: Path + existed: bool + updated: bool + current_checksum: str | None + new_checksum: str + discover_current_checksum: str | None = None + discover_new_checksum: str | None = None + + +def _copy_hook_script(config_path: Path, *, include_discover: bool = True) -> _HookScripts: """Copy bundled hook scripts to a hooks/ dir next to the config file. - Returns (path, already_existed, was_updated, current_checksum, new_checksum). - All values describe the forwarding script, except ``was_updated``, which is - True when either the forwarding or the discovery script was written. - current_checksum is None when the forwarding script did not exist before. + Checksums describe both the forwarding script and, when requested, the + session-start discovery trampoline. """ dest_dir = config_path.parent / "hooks" @@ -1641,25 +1750,49 @@ def _copy_hook_script(config_path: Path) -> tuple[Path, bool, bool, str | None, new_content = source.read_bytes().replace(b"__AGENT_SCAN_VERSION__", version_info.encode()) new_checksum = hashlib.sha256(new_content).hexdigest() - discover_name = "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" - discover_source = hook_pkg.joinpath(discover_name) - discover_dest = dest_dir / discover_name - discover_content = discover_source.read_bytes() + discover_current_checksum: str | None = None + discover_new_checksum: str | None = None discover_updated = False - if not discover_dest.exists() or discover_dest.read_bytes() != discover_content: - discover_dest.write_bytes(discover_content) - rich.print(f"[green]\u2713[/green] Copied hook script to [dim]{discover_dest}[/dim]") - discover_updated = True - if not IS_WINDOWS: - discover_dest.chmod(discover_dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + if include_discover: + discover_name = "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" + discover_source = hook_pkg.joinpath(discover_name) + discover_dest = dest_dir / discover_name + discover_content = discover_source.read_bytes() + discover_new_checksum = hashlib.sha256(discover_content).hexdigest() + discover_existing_content: bytes | None = None + if discover_dest.exists(): + discover_existing_content = discover_dest.read_bytes() + discover_current_checksum = hashlib.sha256(discover_existing_content).hexdigest() + if discover_existing_content != discover_content: + discover_dest.write_bytes(discover_content) + rich.print(f"[green]\u2713[/green] Copied hook script to [dim]{discover_dest}[/dim]") + discover_updated = True + if not IS_WINDOWS: + discover_dest.chmod(discover_dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) if existed and current_checksum == new_checksum: - return dest, existed, discover_updated, current_checksum, new_checksum + return _HookScripts( + dest, + existed, + discover_updated, + current_checksum, + new_checksum, + discover_current_checksum, + discover_new_checksum, + ) 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 _HookScripts( + dest, + existed, + True, + current_checksum, + new_checksum, + discover_current_checksum, + discover_new_checksum, + ) def _remove_hook_script(client: str, config_path: Path) -> None: diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 index 1e62b148..a80db2aa 100644 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -31,6 +31,7 @@ if ($RemoteUrl) { $env:REMOTE_HOOKS_BASE_URL = $RemoteUrl } if ($MachineId) { $env:MACHINE_ID = $MachineId } $bin = if ($AgentScanBin) { $AgentScanBin } elseif ($env:AGENT_SCAN_BIN) { $env:AGENT_SCAN_BIN } else { "snyk-agent-scan" } +if (-not (Get-Command $bin -ErrorAction SilentlyContinue)) { $bin = "snyk-agent-scan" } $arguments = @("guard", "discover", "--client", $Client) if ($ConfigFile) { $arguments += @("--file", $ConfigFile) } diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh index 5f348e72..3981d6da 100755 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.sh +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -1,3 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover "$@" >/dev/null 2>&1 +# The path baked in at install time can go stale: a uvx install resolves to an +# absolute path under ~/.cache/uv that uv later garbage-collects. Fall back to +# PATH rather than exec'ing a binary that is no longer there. +bin="${AGENT_SCAN_BIN:-snyk-agent-scan}" +if ! command -v "$bin" >/dev/null 2>&1; then + bin="snyk-agent-scan" +fi +exec "$bin" guard discover "$@" >/dev/null 2>&1 diff --git a/src/agent_scan/pipelines.py b/src/agent_scan/pipelines.py index c2a0bec2..1cda37bb 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -89,11 +89,15 @@ async def discover_clients_to_inspect( project_folders: list[Path] = [] seen_project_folders: set[Path] = set() for raw_path in inspect_args.project_folders: - project_path = Path(raw_path).expanduser().resolve() - if project_path in seen_project_folders: + project_path = Path(raw_path).expanduser() + try: + key = project_path.resolve() + except OSError: + key = project_path + if key in seen_project_folders: continue - seen_project_folders.add(project_path) - if not project_path.exists(): + seen_project_folders.add(key) + if not key.exists(): logger.warning("Skipping non-existent project folder: %s", project_path) continue project_folders.append(project_path) diff --git a/tests/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index 1deb1f1d..429eb6fa 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -8875,6 +8875,24 @@ def test_explicit_project_folders_gain_ancestors_and_dedup_recorded_roots(tmp_pa assert tmp_path in paths +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") +def test_all_project_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._all_project_folders() == [recorded_link] + paths = discoverer._project_paths_with_ancestors() + assert recorded_link in paths + assert target not in paths + + def test_claude_code_discovers_servers_and_skills_from_explicit_project_without_state_entry(tmp_path): from agent_scan.agents import ClaudeCodeDiscoverer @@ -8986,6 +9004,30 @@ async def test_pipeline_merges_explicit_project_servers_and_skills_into_installe 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_project_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=[], project_folders=[str(project_link)]) + ) + + find.assert_called_once_with(home, project_folders=[project_link]) + + @pytest.mark.asyncio async def test_pipeline_skips_missing_explicit_project_folder_with_warning(tmp_path, caplog): from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect diff --git a/tests/unit/test_cli_config_file.py b/tests/unit/test_cli_config_file.py index 03577602..928f2652 100644 --- a/tests/unit/test_cli_config_file.py +++ b/tests/unit/test_cli_config_file.py @@ -108,6 +108,23 @@ def test_uses_destination_from_active_subparser_when_option_aliases_collide(self assert "control_identifier" in provided assert "machine_id" not in provided + def test_root_option_value_is_not_mistaken_for_subcommand(self): + parser = argparse.ArgumentParser(allow_abbrev=False) + parser.add_argument("--config-file") + subparsers = parser.add_subparsers(dest="command") + scan_parser = subparsers.add_parser("scan", allow_abbrev=False) + scan_parser.add_argument("--control-identifier") + guard_parser = subparsers.add_parser("guard", allow_abbrev=False) + guard_subparsers = guard_parser.add_subparsers(dest="guard_command") + guard_subparsers.add_parser("install", allow_abbrev=False) + + provided = explicitly_provided_dests( + parser, + ["--config-file", "guard", "scan", "--control-identifier", "x"], + ) + + assert "control_identifier" in provided + class TestAbbreviationDisabled: """main() sets allow_abbrev=False so prefix abbreviations are rejected, which diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 06abf686..378702a4 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -66,7 +66,6 @@ _write_cursor_config, ) from agent_scan.models import ClientToInspect, InspectedPath, InspectedServer, RemoteServer, StdioServer -from agent_scan.models.api.v20260710 import ScanPathRequest from agent_scan.models.errors import CouldNotParseMCPConfig, FileNotFoundConfig from agent_scan.pushkeys import GuardEnabledAccessDeniedError @@ -259,6 +258,18 @@ def test_machine_id_powershell_escapes_single_quotes(self): ) assert "-MachineId 'O''Brien-laptop'" in cmd + def test_powershell_escapes_single_quotes_in_all_literals(self): + cmd = _build_hook_command_powershell( + "pk'quoted", + "https://example.com/O'Brien", + Path("C:/Users/O'Brien/hook.ps1"), + "codex", + ) + + assert "-File 'C:/Users/O''Brien/hook.ps1'" 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") @@ -323,6 +334,44 @@ def test_venv_console_script_sibling_is_used_for_dev_invocation(self, tmp_path, assert guard_module._agent_scan_bin() == str(executable.resolve()) + def test_windows_console_script_uses_resolved_argv_zero(self, tmp_path, monkeypatch): + executable = tmp_path / "snyk-agent-scan.exe" + executable.write_text("binary") + executable.chmod(0o755) + monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr(sys, "argv", [str(executable)]) + monkeypatch.setattr(sys, "executable", str(tmp_path / "python.exe")) + + with patch(f"{_G}.IS_WINDOWS", True): + assert guard_module._agent_scan_bin() == str(executable.resolve()) + + def test_windows_venv_console_script_sibling_is_used(self, tmp_path, monkeypatch): + scripts_dir = tmp_path / "Scripts" + scripts_dir.mkdir() + executable = scripts_dir / "snyk-agent-scan.exe" + executable.write_text("binary") + executable.chmod(0o755) + monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr(sys, "argv", [str(tmp_path / "src" / "agent_scan" / "cli.py")]) + monkeypatch.setattr(sys, "executable", str(scripts_dir / "python.exe")) + + with patch(f"{_G}.IS_WINDOWS", True): + assert guard_module._agent_scan_bin() == str(executable.resolve()) + + def test_posix_refuses_windows_console_script_name(self, tmp_path, monkeypatch): + executable = tmp_path / "snyk-agent-scan.exe" + executable.write_text("binary") + executable.chmod(0o755) + monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.setattr(sys, "argv", [str(executable)]) + monkeypatch.setattr(sys, "executable", str(tmp_path / "python")) + + with patch(f"{_G}.IS_WINDOWS", False): + assert guard_module._agent_scan_bin() is None + def test_returns_none_when_no_executable_matches(self, tmp_path, monkeypatch): monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) monkeypatch.setattr(sys, "frozen", False, raising=False) @@ -406,6 +455,23 @@ def test_builds_powershell_command_for_each_client(self, client): r"-AgentScanBin 'C:\Program Files\Snyk\snyk-agent-scan.exe'" ) + def test_powershell_escapes_single_quotes_in_paths(self): + with ( + patch(f"{_G}.IS_WINDOWS", True), + patch(f"{_G}._agent_scan_bin", return_value=r"C:\Users\O'Brien\snyk-agent-scan.exe"), + ): + command = guard_module._build_discover_hook_command( + "pk", + "https://api.snyk.io", + Path(r"C:\Users\O'Brien\discover.ps1"), + "claude-code", + config_path=Path(r"C:\Users\O'Brien\.claude\settings.json"), + ) + + assert r"-File 'C:\Users\O''Brien\discover.ps1'" in command + assert r"-ConfigFile 'C:\Users\O''Brien\.claude\settings.json'" in command + assert r"-AgentScanBin 'C:\Users\O''Brien\snyk-agent-scan.exe'" in command + class TestPrepareClaudeDiscoveryHook: discover_command = ( @@ -597,10 +663,60 @@ def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_pat assert discover_script.read_text() == ( "#!/usr/bin/env bash\nset -euo pipefail\n" - 'exec "${AGENT_SCAN_BIN:-snyk-agent-scan}" guard discover "$@" >/dev/null 2>&1\n' + "# The path baked in at install time can go stale: a uvx install resolves to an\n" + "# absolute path under ~/.cache/uv that uv later garbage-collects. Fall back to\n" + "# PATH rather than exec'ing a binary that is no longer there.\n" + 'bin="${AGENT_SCAN_BIN:-snyk-agent-scan}"\n' + 'if ! command -v "$bin" >/dev/null 2>&1; then\n' + ' bin="snyk-agent-scan"\n' + "fi\n" + 'exec "$bin" guard discover "$@" >/dev/null 2>&1\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" + scripts = guard_module._copy_hook_script(config) + discover_script = scripts.path.with_name("snyk-agent-guard-discover.sh") + + assert scripts.discover_current_checksum is None + assert scripts.discover_new_checksum == hashlib.sha256(discover_script.read_bytes()).hexdigest() + + discover_script.write_text("stale discovery script\n") + scripts = guard_module._copy_hook_script(config) + + assert scripts.discover_current_checksum == hashlib.sha256(b"stale discovery script\n").hexdigest() + assert scripts.discover_new_checksum == hashlib.sha256(discover_script.read_bytes()).hexdigest() + + def test_stale_absolute_binary_falls_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_BIN": str(tmp_path / "deleted" / "snyk-agent-scan"), + "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 marker.read_text().strip() == "guard discover --client claude-code" + def test_copy_restores_missing_discovery_script_when_forwarder_is_current(self, tmp_path): config = tmp_path / "settings.json" main_script, *_ = guard_module._copy_hook_script(config) @@ -1540,6 +1656,31 @@ 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_does_not_write_orphan_discovery_script(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"), + ): + _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 not (tmp_path / "hooks" / "snyk-agent-guard-discover.sh").exists() + def test_detect_after_install(self, tmp_path): install, _, detect, _ = self._import_managed_helpers() path = tmp_path / "requirements.toml" @@ -2189,7 +2330,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", + (dest, True, False, _CURRENT_CHECKSUM, _NEW_CHECKSUM, None, None), + ), "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)), @@ -2250,9 +2394,9 @@ def _print_messages(self, ctx): # _copy_hook_script receives only config_path # --------------------------------------------------------------- - 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) + ctx["copy"].assert_called_once_with(config, include_discover=True) def test_machine_id_forwarded_to_command_and_test_event(self, ctx, tmp_path): self._call(tmp_path, machine_id="machine-42") @@ -2297,9 +2441,10 @@ def test_codex_json_builds_discovery_hook(self, ctx, tmp_path): def test_codex_managed_does_not_build_discovery_hook(self, ctx, tmp_path): ctx["is_toml"].return_value = True - self._call(tmp_path, client="codex", hook_client="codex") + config = self._call(tmp_path, client="codex", hook_client="codex") ctx["build_discover"].assert_not_called() + ctx["copy"].assert_called_once_with(config, include_discover=False) def test_returns_installed_script_path(self, ctx, tmp_path): result = _install_hooks( @@ -2383,7 +2528,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["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None) self._call(tmp_path, config_exists=True) ctx["test_event"].assert_called_once() _, kwargs = ctx["test_event"].call_args @@ -2403,7 +2548,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["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None) self._call(tmp_path) ctx["test_event"].assert_called_once_with( "pk-test", @@ -2416,12 +2561,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=None, + discover_new_checksum=None, 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["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None) self._call(tmp_path) ctx["test_event"].assert_called_once_with( "pk-test", @@ -2434,6 +2581,8 @@ 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=None, + discover_new_checksum=None, machine_id="", ) @@ -2451,12 +2600,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=None, + discover_new_checksum=None, 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["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None) self._call(tmp_path) ctx["test_event"].assert_called_once_with( "pk-test", @@ -2469,12 +2620,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=None, + discover_new_checksum=None, 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["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None) self._call(tmp_path) ctx["test_event"].assert_called_once_with( "pk-test", @@ -2487,6 +2640,8 @@ 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=None, + discover_new_checksum=None, machine_id="", ) @@ -2496,7 +2651,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["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None) self._call(tmp_path) _, kwargs = ctx["test_event"].call_args assert kwargs["current_checksum"] is None @@ -2509,6 +2664,23 @@ 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["copy"].return_value = ( + ctx["dest"], + True, + False, + _CURRENT_CHECKSUM, + _NEW_CHECKSUM, + "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 # --------------------------------------------------------------- @@ -2525,14 +2697,14 @@ 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["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None) 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["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None) ctx["test_event"].return_value = False with pytest.raises(SystemExit): self._call(tmp_path) @@ -2544,10 +2716,11 @@ def test_test_event_failure_cleans_new_discovery_script(self, ctx, tmp_path): ) discover_script = tmp_path / "hooks" / discover_script_name - def copy_scripts(_config_path): + def copy_scripts(_config_path, *, include_discover): + assert include_discover is True discover_script.parent.mkdir(parents=True) discover_script.write_text("#!/bin/sh\n") - return ctx["dest"], False, True, None, _NEW_CHECKSUM + return ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None ctx["copy"].side_effect = copy_scripts ctx["test_event"].return_value = False @@ -2564,7 +2737,7 @@ def test_test_event_failure_keeps_existing_discovery_script(self, ctx, tmp_path) discover_script = tmp_path / "hooks" / discover_script_name discover_script.parent.mkdir(parents=True) discover_script.write_text("existing\n") - ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM) + ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None) ctx["test_event"].return_value = False with pytest.raises(SystemExit): @@ -2573,7 +2746,15 @@ def test_test_event_failure_keeps_existing_discovery_script(self, ctx, 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["copy"].return_value = ( + ctx["dest"], + True, + False, + _CURRENT_CHECKSUM, + _NEW_CHECKSUM, + None, + None, + ) ctx["test_event"].return_value = False with pytest.raises(SystemExit): self._call(tmp_path, minted=True, config_exists=True) @@ -2631,7 +2812,15 @@ def test_status_installed_when_config_written(self, ctx, tmp_path): 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["copy"].return_value = ( + ctx["dest"], + True, + True, + _CURRENT_CHECKSUM, + _NEW_CHECKSUM, + None, + None, + ) ctx["write_claude"].return_value = False self._call(tmp_path, config_exists=True) assert any("hooks installed" in m for m in self._print_messages(ctx)) @@ -2693,6 +2882,21 @@ 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 @@ -2700,8 +2904,13 @@ def test_no_checksums_omits_hooks_script(self): class TestServersDiscoveredPayload: @staticmethod - def _client(*, mcp_configs, name="claude code", path="/Users/me/.claude"): - return ClientToInspect(name=name, client_path=path, mcp_configs=mcp_configs, skills_dirs={}) + 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( @@ -2715,29 +2924,30 @@ def test_builds_one_entry_per_client_and_merges_config_paths(self): type="http", headers={"Authorization": "Bearer remote-secret"}, ) + home = Path.home() clients = [ self._client( mcp_configs={ - "/Users/me/.claude.json": [("github", stdio)], - "/Users/me/project/.mcp.json": [("remote", remote)], + (home / ".claude.json").as_posix(): [("github", stdio)], + (home / "project" / ".mcp.json").as_posix(): [("remote", remote)], } ), - self._client(mcp_configs={}, name="cursor", path="/Users/me/.cursor"), + 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", "/Users/me/.claude"), - ("cursor", "/Users/me/.cursor"), + ("claude code", "~/.claude"), + ("cursor", "~/.cursor"), ] assert [(server["name"], server["config_path"]) for server in result[0]["servers"]] == [ - ("github", "/Users/me/.claude.json"), - ("remote", "/Users/me/project/.mcp.json"), + ("github", (home / ".claude.json").as_posix()), + ("remote", (home / "project" / ".mcp.json").as_posix()), ] assert result[1]["servers"] == [] - def test_skips_config_discovery_errors(self): + def test_reports_config_discovery_errors(self): client = self._client( mcp_configs={ "/bad.json": CouldNotParseMCPConfig(message="bad", traceback=None), @@ -2749,6 +2959,7 @@ def test_skips_config_discovery_errors(self): 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( @@ -2761,11 +2972,36 @@ def test_client_with_only_error_configs_still_emits_entry(self): 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( @@ -2776,7 +3012,7 @@ def test_matches_scan_path_request_wire_shape(self): result = guard_module._servers_discovered_entries([client]) - expected = ScanPathRequest.from_inspected(inspected).model_dump(mode="json") + 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"} @@ -2840,6 +3076,18 @@ def test_threads_explicit_project_folders_to_inspect_args(self): assert args.project_folders == ["/repo/one", "/repo/two"] assert result == [] + @pytest.mark.parametrize( + "raw_value,expected", + [(None, 60.0), ("garbage", 60.0), ("0", 60.0), ("-1", 60.0), ("2.5", 2.5)], + ) + def test_discovery_timeout_environment_parsing(self, raw_value, expected, monkeypatch): + if raw_value is None: + monkeypatch.delenv("AGENT_SCAN_DISCOVERY_TIMEOUT_SECONDS", raising=False) + else: + monkeypatch.setenv("AGENT_SCAN_DISCOVERY_TIMEOUT_SECONDS", raw_value) + + assert guard_module._discovery_timeout_seconds() == expected + class TestInvokeHookScript: def test_posix_invocation_sets_machine_id(self, monkeypatch): @@ -3023,6 +3271,29 @@ def test_discovery_exception_does_not_invoke_script(self): run.assert_not_called() assert "discovery failed" in rich_mock.print.call_args.args[0] + def test_discovery_timeout_warns_without_invoking_script(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", return_value=0.01), + patch("subprocess.run") as run, + patch(f"{_G}.rich") as rich_mock, + ): + started = test_time.monotonic() + result = guard_module._send_servers_discovered_event("pk", "url", "cursor", Path("/hook.sh"), "") + elapsed = test_time.monotonic() - started + + assert result is False + assert elapsed < 0.2 + run.assert_not_called() + assert "timed out" in rich_mock.print.call_args.args[0] + class TestGuardInstallMachineIdCli: @pytest.mark.parametrize("flag", ["--machine-id", "--control-identifier"]) @@ -3177,6 +3448,7 @@ def test_hook_stdin_reads_cwd_for_claude_code(self, tmp_path, monkeypatch): self._write_forwarder(config) 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 ( @@ -3202,6 +3474,7 @@ def test_hook_stdin_reads_cwd_for_codex(self, tmp_path, monkeypatch): self._write_forwarder(config) 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"}' ) @@ -3224,6 +3497,7 @@ def test_hook_stdin_accepts_workspace_roots_list(self, tmp_path, monkeypatch): self._write_forwarder(config) 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"}' ) @@ -3251,6 +3525,7 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") stdin = MagicMock() + stdin.isatty.return_value = False stdin.read.return_value = "not-json" discover = MagicMock(return_value=[]) with ( @@ -3270,6 +3545,55 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): discover.assert_called_once_with([]) assert json.loads(run.call_args.kwargs["input"])["session_id"] == "session-start-server-discovery" + def test_tty_stdin_is_not_read(self, tmp_path, monkeypatch): + config = tmp_path / "settings.json" + self._write_forwarder(config) + 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("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + 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([]) + + def test_pipe_that_never_closes_does_not_block_discovery(self, tmp_path, monkeypatch): + import time as test_time + + config = tmp_path / "settings.json" + self._write_forwarder(config) + 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("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + 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", [ @@ -3302,6 +3626,7 @@ def test_hook_stdin_forwards_valid_session_marker_or_falls_back( 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), @@ -3320,6 +3645,7 @@ def test_stdin_is_never_read_without_client(self, tmp_path, monkeypatch): self._write_forwarder(config) 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), From 51a68acb03614b69e2fbfa4e7e5bdc9d8e25dd93 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Mon, 24 Aug 2026 15:30:00 +0200 Subject: [PATCH 22/58] fix: bound discovery timeout overrides --- src/agent_scan/guard.py | 7 ++++++- tests/unit/test_guard.py | 11 ++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 4703a1d6..a3deef41 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -335,11 +335,16 @@ def run() -> None: def _discovery_timeout_seconds() -> float: + import math + import threading + try: value = float(os.environ.get("AGENT_SCAN_DISCOVERY_TIMEOUT_SECONDS", "")) except ValueError: return _DEFAULT_DISCOVERY_TIMEOUT_SECONDS - return value if value > 0 else _DEFAULT_DISCOVERY_TIMEOUT_SECONDS + if not math.isfinite(value) or not 0 < value <= threading.TIMEOUT_MAX: + return _DEFAULT_DISCOVERY_TIMEOUT_SECONDS + return value def _read_hook_payload() -> str: diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 378702a4..6d7ecc90 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -3078,7 +3078,16 @@ def test_threads_explicit_project_folders_to_inspect_args(self): @pytest.mark.parametrize( "raw_value,expected", - [(None, 60.0), ("garbage", 60.0), ("0", 60.0), ("-1", 60.0), ("2.5", 2.5)], + [ + (None, 60.0), + ("garbage", 60.0), + ("0", 60.0), + ("-1", 60.0), + ("nan", 60.0), + ("inf", 60.0), + ("1e100", 60.0), + ("2.5", 2.5), + ], ) def test_discovery_timeout_environment_parsing(self, raw_value, expected, monkeypatch): if raw_value is None: From 878d3a95655b640c41fd47baea3ea04c2fe072d4 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Mon, 24 Aug 2026 16:11:21 +0200 Subject: [PATCH 23/58] feat: send discovery events directly --- docs/cli-reference.md | 4 +- src/agent_scan/cli.py | 10 +- src/agent_scan/guard.py | 32 +-- src/agent_scan/hook_events.py | 66 +++++ .../hooks/snyk-agent-guard-discover.ps1 | 4 - tests/e2e/test_guard_install.py | 14 +- tests/unit/test_guard.py | 244 ++++++++---------- tests/unit/test_hook_events.py | 81 ++++++ 8 files changed, 267 insertions(+), 188 deletions(-) create mode 100644 src/agent_scan/hook_events.py create mode 100644 tests/unit/test_hook_events.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2c13eac6..51535d1f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -372,12 +372,12 @@ Installation also configures a fire-and-forget session-start hook that reports d snyk-agent-scan guard discover [OPTIONS] ``` -This internal command is invoked by the SessionStart hook configured by `guard install`; it is not normally run by hand. +This internal command is invoked by the SessionStart hook configured by `guard install`. It discovers MCP servers locally +and sends the resulting 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. | -| `--file PATH` | string | — | Override the client configuration path used to locate the forwarding script. | | `--client {claude-code,cursor,codex}` | string | — | Hook client whose payload and endpoint conventions should be used. | ### `guard uninstall` diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index 7c81b27b..7cd469ae 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -1016,7 +1016,7 @@ def main(): "discover", allow_abbrev=False, help=( - "Run MCP server discovery and send a SessionStartServerDiscovery event through the installed hooks " + "Run MCP server discovery and send a SessionStartServerDiscovery event directly to Agent Monitor " "(used by the async session-start hooks that guard install configures)" ), ) @@ -1026,14 +1026,6 @@ def main(): default=None, help="Remote hooks base URL (default: REMOTE_HOOKS_BASE_URL or https://api.snyk.io)", ) - guard_discover_parser.add_argument( - "--file", - type=str, - default=None, - help=( - "Override the hook config file path used to locate the forwarding script (default: ~/.claude/settings.json)" - ), - ) guard_discover_parser.add_argument( "--client", type=str, diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index a3deef41..5c98c5cf 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -19,6 +19,7 @@ import rich +from agent_scan.hook_events import send_hook_event from agent_scan.pushkeys import ( GuardEnabledAccessDeniedError, _is_localhost, @@ -276,10 +277,10 @@ def _run_install(args) -> None: minted = not headless # True if we minted the key in this run installed_any = False - first_installed: tuple[str, Path] | None = None + first_installed_client: str | None = None try: for c in clients: - dest_path = _install_hooks( + _install_hooks( c, _hook_client_name(c), push_key, @@ -292,8 +293,8 @@ def _run_install(args) -> None: snyk_token, machine_id, ) - if first_installed is None: - first_installed = (_hook_client_name(c), dest_path) + if first_installed_client is None: + first_installed_client = _hook_client_name(c) installed_any = True except BaseException: if minted: @@ -307,8 +308,8 @@ def _run_install(args) -> None: _revoke_after_failure(url, tenant_id, snyk_token, push_key) raise - if first_installed is not None: - _send_servers_discovered_event(push_key, url, *first_installed, machine_id) + if first_installed_client is not None: + _send_servers_discovered_event(push_key, url, first_installed_client, machine_id) def _run_with_timeout(func: Callable[[], _T], timeout: float) -> _T: @@ -366,15 +367,6 @@ def _run_discover(args) -> int: url = getattr(args, "url", None) or os.environ.get("REMOTE_HOOKS_BASE_URL") or DEFAULT_REMOTE_URL machine_id = (os.environ.get("MACHINE_ID", "") or "").strip() - config_path = Path(getattr(args, "file", None) or CLAUDE_SETTINGS_PATH) - script_name = "snyk-agent-guard.ps1" if IS_WINDOWS else "snyk-agent-guard.sh" - script_path = config_path.parent / "hooks" / script_name - if not script_path.exists(): - rich.print( - f"[bold red]Error:[/bold red] Agent Guard forwarding script not found: {script_path}. " - "Run guard install first." - ) - return 1 project_folders: list[str] = [] session_id = "" @@ -407,7 +399,6 @@ def _run_discover(args) -> int: push_key, url, hook_client or "claude-code", - script_path, machine_id, event_name="SessionStartServerDiscovery", session_marker=session_id or "session-start-server-discovery", @@ -531,7 +522,6 @@ def _install_hooks( tenant_id=tenant_id, machine_id=machine_id, hook_client=hook_client, - config_path=config_path, ) prepared_config, prepared_content, hooks_diff, preserved = _prepare_client_config( client, @@ -1323,7 +1313,6 @@ def _send_servers_discovered_event( push_key: str, url: str, hook_client: str, - script_path: Path, machine_id: str, *, event_name: str = "serversDiscovered", @@ -1351,7 +1340,7 @@ def _send_servers_discovered_event( redact_push_keys_in_data(payload_dict) payload = json.dumps(payload_dict) - ok, detail = _invoke_hook_script(script_path, hook_client, push_key, url, payload, machine_id) + ok, detail = send_hook_event(url, hook_client, push_key, payload, machine_id) if ok: server_count = sum(len(entry.get("servers", [])) for entry in servers) noun = "server" if server_count == 1 else "servers" @@ -1625,7 +1614,6 @@ def _build_discover_hook_command( script_path: Path, hook_client: str, *, - config_path: Path, tenant_id: str = "", machine_id: str = "", ) -> str: @@ -1635,7 +1623,6 @@ def _build_discover_hook_command( url, script_path, hook_client, - config_path=config_path, tenant_id=tenant_id, machine_id=machine_id, ) @@ -1652,7 +1639,6 @@ def _build_discover_hook_command( parts.append(f"AGENT_SCAN_BIN={_shell_quote(agent_scan_bin)}") parts.append(f"bash {_shell_quote(script_path.as_posix())}") parts.append(f"--client {_shell_quote(hook_client)}") - parts.append(f"--file {_shell_quote(config_path.as_posix())}") return " ".join(parts) @@ -1662,7 +1648,6 @@ def _build_discover_hook_command_powershell( script_path: Path, hook_client: str, *, - config_path: Path, tenant_id: str = "", machine_id: str = "", ) -> str: @@ -1672,7 +1657,6 @@ def _build_discover_hook_command_powershell( ) if machine_id: command += f" -MachineId {_ps_quote(machine_id)}" - command += f" -ConfigFile {_ps_quote(str(config_path))}" agent_scan_bin = _agent_scan_bin() if agent_scan_bin is not None: command += f" -AgentScanBin {_ps_quote(agent_scan_bin)}" diff --git a/src/agent_scan/hook_events.py b/src/agent_scan/hook_events.py new file mode 100644 index 00000000..22024f7f --- /dev/null +++ b/src/agent_scan/hook_events.py @@ -0,0 +1,66 @@ +"""Direct delivery of Agent Guard hook events to Agent Monitor.""" + +from __future__ import annotations + +import base64 +import json +import sys +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from agent_scan.hook_version import HOOK_VERSION +from agent_scan.utils import get_hostname, get_username +from agent_scan.version import version_info + +_HOOK_ENDPOINTS = { + "claude-code": "/hidden/agent-monitor/hooks/claude-code", + "cursor": "/hidden/agent-monitor/hooks/cursor", + "codex": "/hidden/agent-monitor/hooks/codex", +} +_HOOK_REQUEST_TIMEOUT_SECONDS = 15 + + +def send_hook_event( + base_url: str, + hook_client: str, + push_key: str, + payload: str, + machine_id: str = "", +) -> tuple[bool, str]: + """POST a hook event using the same wire contract as the hook scripts.""" + endpoint = _HOOK_ENDPOINTS.get(hook_client) + if endpoint is None: + return False, f"unknown client: {hook_client}" + + hostname = get_hostname() + x_user = json.dumps( + { + "hostname": hostname, + "username": get_username(), + "identifier": machine_id or hostname, + }, + separators=(",", ":"), + ) + encoded_payload = base64.b64encode(payload.encode()).decode() + body = f"base64:{encoded_payload}".encode() + script_extension = "ps1" if sys.platform == "win32" else "sh" + url = f"{base_url.rstrip('/')}{endpoint}?version={HOOK_VERSION}" + + request = Request(url, data=body, method="POST") + request.add_header("User-Agent", f"snyk/snyk-agent-guard.{script_extension} Agent Scan v{version_info}") + request.add_header("X-User", x_user) + request.add_header("Content-Type", "text/plain") + request.add_header("X-Client-Id", push_key) + + try: + with urlopen(request, timeout=_HOOK_REQUEST_TIMEOUT_SECONDS) as response: + status = getattr(response, "status", 200) + if status >= 400: + return False, f"HTTP {status}" + return True, "" + except HTTPError as error: + return False, f"HTTP {error.code}" + except (TimeoutError, URLError) as error: + return False, str(error) + except Exception as error: + 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 index a80db2aa..d6daa096 100644 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -17,9 +17,6 @@ param( [Parameter(Mandatory=$false)] [string]$MachineId, - [Parameter(Mandatory=$false)] - [string]$ConfigFile, - [Parameter(Mandatory=$false)] [string]$AgentScanBin ) @@ -34,7 +31,6 @@ $bin = if ($AgentScanBin) { $AgentScanBin } elseif ($env:AGENT_SCAN_BIN) { $env: if (-not (Get-Command $bin -ErrorAction SilentlyContinue)) { $bin = "snyk-agent-scan" } $arguments = @("guard", "discover", "--client", $Client) -if ($ConfigFile) { $arguments += @("--file", $ConfigFile) } $reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8, $true) $payload = $reader.ReadToEnd() diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index 85d27f1e..4448f11f 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -89,8 +89,8 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): 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) in discover_command - assert ("-ConfigFile" if os.name == "nt" else "--file") 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", "serversDiscovered", @@ -103,7 +103,7 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): assert json.loads(discovered["headers"]["X-User"])["identifier"] == "e2e-machine-id" discover_result = subprocess.run( - [*agent_scan_cmd, "guard", "discover", "--file", str(config_file)], + [*agent_scan_cmd, "guard", "discover"], capture_output=True, text=True, timeout=60, @@ -156,10 +156,10 @@ def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): ] assert len(discovery_entries) == 1 assert set(discovery_entries[0]) == {"command"} - assert str(config_file) in 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", "--file", str(config_file)], + [*agent_scan_cmd, "guard", "discover", "--client", "cursor"], input=json.dumps({"workspace_roots": [str(tmp_path)], "conversation_id": "e2e-conversation"}), capture_output=True, text=True, @@ -211,10 +211,10 @@ def test_guard_install_codex(self, agent_scan_cmd, tmp_path, fake_hook_server): 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) in discovery_groups[0]["hooks"][0]["command"] + assert str(config_file) not in discovery_groups[0]["hooks"][0]["command"] discover_result = subprocess.run( - [*agent_scan_cmd, "guard", "discover", "--client", "codex", "--file", str(config_file)], + [*agent_scan_cmd, "guard", "discover", "--client", "codex"], input=json.dumps({"cwd": str(tmp_path), "session_id": "e2e"}), capture_output=True, text=True, diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 6d7ecc90..fd792b5c 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -391,7 +391,6 @@ def test_client_payload_fields_match_hook_schemas(self, client, expected_field): @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): - config_path = Path("/x/config with spaces/settings.json") with ( patch(f"{_G}.IS_WINDOWS", False), patch(f"{_G}._agent_scan_bin", return_value="/opt/Snyk's bin/snyk-agent-scan"), @@ -401,7 +400,6 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): "https://api.snyk.io", Path("/x/snyk-agent-guard-discover.sh"), client, - config_path=config_path, tenant_id="tenant", machine_id="machine", ) @@ -411,9 +409,7 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): assert "TENANT_ID='tenant'" in command assert "MACHINE_ID='machine'" in command assert "AGENT_SCAN_BIN='/opt/Snyk'\"'\"'s bin/snyk-agent-scan'" in command - assert command.endswith( - f"bash '/x/snyk-agent-guard-discover.sh' --client '{client}' --file '/x/config with spaces/settings.json'" - ) + assert command.endswith(f"bash '/x/snyk-agent-guard-discover.sh' --client '{client}'") assert _is_agent_scan_command(command) def test_omits_agent_scan_binary_when_unresolved(self): @@ -426,11 +422,10 @@ def test_omits_agent_scan_binary_when_unresolved(self): "https://api.snyk.io", Path("/x/snyk-agent-guard-discover.sh"), "cursor", - config_path=Path("/x/hooks.json"), ) assert "AGENT_SCAN_BIN" not in command - assert command.endswith("--client 'cursor' --file '/x/hooks.json'") + assert command.endswith("--client 'cursor'") @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) def test_builds_powershell_command_for_each_client(self, client): @@ -443,7 +438,6 @@ def test_builds_powershell_command_for_each_client(self, client): "https://api.snyk.io", Path(r"C:\hooks\snyk-agent-guard-discover.ps1"), client, - config_path=Path(r"C:\config path\hooks.json"), tenant_id="ignored", machine_id="machine's-id", ) @@ -451,7 +445,6 @@ def test_builds_powershell_command_for_each_client(self, client): 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"-ConfigFile 'C:\config path\hooks.json' " r"-AgentScanBin 'C:\Program Files\Snyk\snyk-agent-scan.exe'" ) @@ -465,11 +458,9 @@ def test_powershell_escapes_single_quotes_in_paths(self): "https://api.snyk.io", Path(r"C:\Users\O'Brien\discover.ps1"), "claude-code", - config_path=Path(r"C:\Users\O'Brien\.claude\settings.json"), ) assert r"-File 'C:\Users\O''Brien\discover.ps1'" in command - assert r"-ConfigFile 'C:\Users\O''Brien\.claude\settings.json'" in command assert r"-AgentScanBin 'C:\Users\O''Brien\snyk-agent-scan.exe'" in command @@ -2412,15 +2403,14 @@ def test_claude_builds_and_prepares_async_discovery_hook(self, ctx, tmp_path): "tenant_id": "tid-1", "machine_id": "machine-42", "hook_client": "claude-code", - "config_path": tmp_path / "config.json", } assert ctx["prep_claude"].call_args.kwargs["discover_command"] == "discover-cmd" def test_cursor_builds_discovery_hook(self, ctx, tmp_path): - config = self._call(tmp_path, client="cursor", hook_client="cursor") + self._call(tmp_path, client="cursor", hook_client="cursor") ctx["build_discover"].assert_called_once() - assert ctx["build_discover"].call_args.kwargs["config_path"] == config + 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): @@ -2432,10 +2422,10 @@ def test_windows_builds_discovery_hook(self, ctx, tmp_path): assert ctx["prep_claude"].call_args.kwargs["discover_command"] == "discover-cmd" def test_codex_json_builds_discovery_hook(self, ctx, tmp_path): - config = self._call(tmp_path, client="codex", hook_client="codex") + self._call(tmp_path, client="codex", hook_client="codex") ctx["build_discover"].assert_called_once() - assert ctx["build_discover"].call_args.kwargs["config_path"] == config + 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_does_not_build_discovery_hook(self, ctx, tmp_path): @@ -3158,21 +3148,22 @@ class TestSendServersDiscoveredEvent: def _capture(hook_client="claude-code", entries=None, machine_id="machine-42"): captured = {} - def fake_run(cmd, *, input, **kwargs): - captured["cmd"] = cmd - captured["payload"] = json.loads(input) - captured["env"] = kwargs["env"] - return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + def fake_send(url, client, push_key, payload, identifier): + captured.update( + url=url, + client=client, + push_key=push_key, + payload=json.loads(payload), + machine_id=identifier, + ) + return True, "" with ( - patch(f"{_G}.IS_WINDOWS", False), patch(f"{_G}._discover_servers_payload", return_value=[] if entries is None else entries), - patch("subprocess.run", side_effect=fake_run), + 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, Path("/hook.sh"), machine_id - ) + ok = guard_module._send_servers_discovered_event("pk-test", "https://api.snyk.io", hook_client, machine_id) return ok, captured @pytest.mark.parametrize( @@ -3193,7 +3184,10 @@ def test_payload_contract_for_client(self, hook_client, id_key): assert isinstance(payload["discovery_duration_ms"], int) assert payload["discovery_duration_ms"] >= 0 assert push_key not in json.dumps(payload) - assert captured["env"]["MACHINE_ID"] == "machine-42" + 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=[]) @@ -3203,20 +3197,19 @@ def test_empty_discovery_is_still_sent(self): def test_event_name_and_session_marker_can_be_overridden(self): captured = {} - def fake_run(cmd, *, input, **kwargs): - captured["payload"] = json.loads(input) - return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + def fake_send(_url, _client, _push_key, payload, _machine_id): + captured["payload"] = json.loads(payload) + return True, "" with ( patch(f"{_G}._discover_servers_payload", return_value=[]), - patch("subprocess.run", side_effect=fake_run), + 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", - Path("/hook.sh"), "machine-42", event_name="SessionStartServerDiscovery", session_marker="session-start-server-discovery", @@ -3229,58 +3222,46 @@ def fake_run(cmd, *, input, **kwargs): def test_payload_includes_discovery_duration_ms_from_monotonic_clock(self): captured = {} - def fake_run(cmd, *, input, **kwargs): - captured["payload"] = json.loads(input) - return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + def fake_send(_url, _client, _push_key, payload, _machine_id): + captured["payload"] = json.loads(payload) + return True, "" with ( - patch(f"{_G}.IS_WINDOWS", False), patch(f"{_G}._discover_servers_payload", return_value=[]), - patch("subprocess.run", side_effect=fake_run), + 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", Path("/hook.sh"), "machine-42" + "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_nonzero_exit_warns_and_returns_false(self): - completed = subprocess.CompletedProcess([], 2, stdout="", stderr="failed") + def test_send_failure_warns_and_returns_false(self): with ( patch(f"{_G}._discover_servers_payload", return_value=[]), - patch("subprocess.run", return_value=completed), + 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", Path("/hook.sh"), "") + result = guard_module._send_servers_discovered_event("pk", "url", "cursor", "") assert result is False - assert "failed" in rich_mock.print.call_args.args[0] + assert "HTTP 500" in rich_mock.print.call_args.args[0] - def test_timeout_warns_and_returns_false(self): - with ( - patch(f"{_G}._discover_servers_payload", return_value=[]), - patch("subprocess.run", side_effect=subprocess.TimeoutExpired("bash", 15)), - patch(f"{_G}.rich") as rich_mock, - ): - result = guard_module._send_servers_discovered_event("pk", "url", "cursor", Path("/hook.sh"), "") - assert result is False - assert "timeout" in rich_mock.print.call_args.args[0] - - def test_discovery_exception_does_not_invoke_script(self): + def test_discovery_exception_does_not_send(self): with ( patch(f"{_G}._discover_servers_payload", side_effect=RuntimeError("discovery failed")), - patch("subprocess.run") as run, + 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", Path("/hook.sh"), "") + result = guard_module._send_servers_discovered_event("pk", "url", "cursor", "") assert result is False - run.assert_not_called() + send.assert_not_called() assert "discovery failed" in rich_mock.print.call_args.args[0] - def test_discovery_timeout_warns_without_invoking_script(self): + def test_discovery_timeout_warns_without_sending(self): import asyncio import time as test_time @@ -3291,16 +3272,16 @@ async def slow_discovery(_inspect_args): with ( patch("agent_scan.pipelines.discover_clients_to_inspect", side_effect=slow_discovery), patch(f"{_G}._discovery_timeout_seconds", return_value=0.01), - patch("subprocess.run") as run, + 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", Path("/hook.sh"), "") + result = guard_module._send_servers_discovered_event("pk", "url", "cursor", "") elapsed = test_time.monotonic() - started assert result is False assert elapsed < 0.2 - run.assert_not_called() + send.assert_not_called() assert "timed out" in rich_mock.print.call_args.args[0] @@ -3319,13 +3300,13 @@ def test_guard_install_accepts_machine_id_aliases(self, flag, monkeypatch): class TestGuardDiscoverCli: - def test_parses_url_and_file(self, monkeypatch): + def test_parses_url(self, monkeypatch): from agent_scan import cli monkeypatch.setattr( sys, "argv", - ["agent-scan", "guard", "discover", "--url", "https://hooks.example", "--file", "/tmp/settings.json"], + ["agent-scan", "guard", "discover", "--url", "https://hooks.example"], ) with patch(f"{_G}.run_guard", return_value=0) as run: with pytest.raises(SystemExit) as exc: @@ -3335,7 +3316,16 @@ def test_parses_url_and_file(self, monkeypatch): args = run.call_args.args[0] assert args.guard_command == "discover" assert args.url == "https://hooks.example" - assert args.file == "/tmp/settings.json" + assert not hasattr(args, "file") + + def test_rejects_removed_file_option(self, monkeypatch): + from agent_scan import cli + + monkeypatch.setattr(sys, "argv", ["agent-scan", "guard", "discover", "--file", "/tmp/settings.json"]) + 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): @@ -3371,42 +3361,36 @@ def _args(config: Path, url=None, **overrides): values = { "guard_command": "discover", "url": url, - "file": str(config), "client": None, } values.update(overrides) return SimpleNamespace(**values) - @staticmethod - def _write_forwarder(config: Path): - script = config.parent / "hooks" / "snyk-agent-guard.sh" - script.parent.mkdir(parents=True) - script.write_text("#!/bin/sh\n") - return script - def test_happy_path_sends_session_start_discovery_from_environment(self, tmp_path, monkeypatch): config = tmp_path / "custom" / "settings.json" - script = self._write_forwarder(config) captured = {} - def fake_run(cmd, *, input, **kwargs): - captured["cmd"] = cmd - captured["payload"] = json.loads(input) - captured["env"] = kwargs["env"] - return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + def fake_send(url, client, push_key, payload, machine_id): + 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("subprocess.run", side_effect=fake_run), + 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 - assert captured["cmd"] == ["bash", str(script), "--client", "claude-code"] duration = captured["payload"].pop("discovery_duration_ms") assert isinstance(duration, int) assert duration >= 0 @@ -3415,46 +3399,48 @@ def fake_run(cmd, *, input, **kwargs): "servers": [], "session_id": "session-start-server-discovery", } - assert captured["env"]["PUSH_KEY"] == "env-pk" - assert captured["env"]["REMOTE_HOOKS_BASE_URL"] == "https://env-hooks.example" - assert captured["env"]["MACHINE_ID"] == "env-machine" + 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" - self._write_forwarder(config) 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("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, + 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 run.call_args.kwargs["env"]["REMOTE_HOOKS_BASE_URL"] == "https://flag-hooks.example" + 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" - self._write_forwarder(config) monkeypatch.delenv("PUSH_KEY", raising=False) - with patch("subprocess.run") as run: + with patch(f"{_G}.send_hook_event") as send: result = guard_module.run_guard(self._args(config)) assert result == 1 - run.assert_not_called() + send.assert_not_called() - def test_missing_forwarding_script_returns_one(self, tmp_path, monkeypatch): + def test_no_forwarding_script_is_needed(self, tmp_path, monkeypatch): monkeypatch.setenv("PUSH_KEY", "env-pk") - with patch("subprocess.run") as run: + 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 == 1 - run.assert_not_called() + 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" - self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") stdin = MagicMock() stdin.isatty.return_value = False @@ -3463,7 +3449,7 @@ def test_hook_stdin_reads_cwd_for_claude_code(self, tmp_path, monkeypatch): with ( patch.object(sys, "stdin", stdin), patch(f"{_G}._discover_servers_payload", discover), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, + patch(f"{_G}.send_hook_event", return_value=(True, "")) as send, patch(f"{_G}.rich"), ): result = guard_module.run_guard( @@ -3476,11 +3462,10 @@ def test_hook_stdin_reads_cwd_for_claude_code(self, tmp_path, monkeypatch): assert result == 0 stdin.read.assert_called_once_with(1024 * 1024) discover.assert_called_once_with(["/session/project"]) - assert json.loads(run.call_args.kwargs["input"])["session_id"] == "session" + 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" - self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") stdin = MagicMock() stdin.isatty.return_value = False @@ -3491,7 +3476,7 @@ def test_hook_stdin_reads_cwd_for_codex(self, tmp_path, monkeypatch): with ( patch.object(sys, "stdin", stdin), patch(f"{_G}._discover_servers_payload", discover), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, + 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")) @@ -3499,11 +3484,10 @@ def test_hook_stdin_reads_cwd_for_codex(self, tmp_path, monkeypatch): assert result == 0 stdin.read.assert_called_once_with(1024 * 1024) discover.assert_called_once_with(["/session/project"]) - assert json.loads(run.call_args.kwargs["input"])["session_id"] == "session" + 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" - self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") stdin = MagicMock() stdin.isatty.return_value = False @@ -3514,24 +3498,18 @@ def test_hook_stdin_accepts_workspace_roots_list(self, tmp_path, monkeypatch): with ( patch.object(sys, "stdin", stdin), patch(f"{_G}._discover_servers_payload", discover), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, + 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"]) - assert run.call_args.args[0] == [ - "bash", - str(config.parent / "hooks" / "snyk-agent-guard.sh"), - "--client", - "cursor", - ] - assert json.loads(run.call_args.kwargs["input"])["conversation_id"] == "conversation" + 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" - self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") stdin = MagicMock() stdin.isatty.return_value = False @@ -3540,7 +3518,7 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): with ( patch.object(sys, "stdin", stdin), patch(f"{_G}._discover_servers_payload", discover), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, + patch(f"{_G}.send_hook_event", return_value=(True, "")) as send, patch(f"{_G}.rich"), ): result = guard_module.run_guard( @@ -3552,11 +3530,10 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): assert result == 0 discover.assert_called_once_with([]) - assert json.loads(run.call_args.kwargs["input"])["session_id"] == "session-start-server-discovery" + 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" - self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") stdin = MagicMock() stdin.isatty.return_value = True @@ -3566,7 +3543,7 @@ def test_tty_stdin_is_not_read(self, tmp_path, monkeypatch): with ( patch.object(sys, "stdin", stdin), patch(f"{_G}._discover_servers_payload", discover), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch(f"{_G}.send_hook_event", return_value=(True, "")), patch(f"{_G}.rich"), ): result = guard_module.run_guard(self._args(config, client="claude-code")) @@ -3579,7 +3556,6 @@ def test_pipe_that_never_closes_does_not_block_discovery(self, tmp_path, monkeyp import time as test_time config = tmp_path / "settings.json" - self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") release_read = threading.Event() stdin = MagicMock() @@ -3591,7 +3567,7 @@ def test_pipe_that_never_closes_does_not_block_discovery(self, tmp_path, monkeyp patch.object(sys, "stdin", stdin), patch(f"{_G}._STDIN_READ_TIMEOUT_SECONDS", 0.01), patch(f"{_G}._discover_servers_payload", return_value=[]), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch(f"{_G}.send_hook_event", return_value=(True, "")), patch(f"{_G}.rich"), ): started = test_time.monotonic() @@ -3631,7 +3607,6 @@ def test_hook_stdin_forwards_valid_session_marker_or_falls_back( expected_marker, ): config = tmp_path / "settings.json" - self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") hook_payload = {} if session_value is None else {session_field: session_value} stdin = MagicMock() @@ -3640,18 +3615,17 @@ def test_hook_stdin_forwards_valid_session_marker_or_falls_back( with ( patch.object(sys, "stdin", stdin), patch(f"{_G}._discover_servers_payload", return_value=[]), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, + 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(run.call_args.kwargs["input"]) + event_payload = json.loads(send.call_args.args[3]) assert event_payload[event_session_field] == expected_marker def test_stdin_is_never_read_without_client(self, tmp_path, monkeypatch): config = tmp_path / "settings.json" - self._write_forwarder(config) monkeypatch.setenv("PUSH_KEY", "env-pk") stdin = MagicMock() stdin.isatty.return_value = False @@ -3659,7 +3633,7 @@ def test_stdin_is_never_read_without_client(self, tmp_path, monkeypatch): with ( patch.object(sys, "stdin", stdin), patch(f"{_G}._discover_servers_payload", return_value=[]), - patch("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")), + patch(f"{_G}.send_hook_event", return_value=(True, "")), patch(f"{_G}.rich"), ): result = guard_module.run_guard(self._args(config)) @@ -3667,36 +3641,22 @@ def test_stdin_is_never_read_without_client(self, tmp_path, monkeypatch): assert result == 0 stdin.read.assert_not_called() - def test_windows_resolves_powershell_forwarder(self, tmp_path, monkeypatch): + def test_windows_uses_direct_sender(self, tmp_path, monkeypatch): config = tmp_path / "hooks.json" - script = config.parent / "hooks" / "snyk-agent-guard.ps1" - script.parent.mkdir(parents=True) - script.write_text("# forwarder\n") 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("subprocess.run", return_value=subprocess.CompletedProcess([], 0, "", "")) as run, + 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 run.call_args.args[0] == [ - "powershell", - "-File", - str(script), - "-Client", - "codex", - "-PushKey", - "env-pk", - "-RemoteUrl", - "https://env-hooks.example", - "-MachineId", - "env-machine", - ] + assert send.call_args.args[:3] == ("https://env-hooks.example", "codex", "env-pk") + assert send.call_args.args[4] == "env-machine" class TestRunInstallSendsServersDiscovered: @@ -3721,7 +3681,7 @@ def _fake_paths(tmp_path, installed): paths[client] = path return paths - def test_single_client_sends_once_using_installed_script(self, tmp_path, monkeypatch): + def test_single_client_sends_once_directly(self, tmp_path, monkeypatch): monkeypatch.setenv("PUSH_KEY", "headless-pk") script = Path("/installed/claude/hook.sh") with ( @@ -3731,7 +3691,7 @@ def test_single_client_sends_once_using_installed_script(self, tmp_path, monkeyp _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", script, "machine-42") + send.assert_called_once_with("headless-pk", "https://api.snyk.io", "claude-code", "machine-42") def test_cursor_install_uses_cursor_endpoint(self, tmp_path, monkeypatch): monkeypatch.setenv("PUSH_KEY", "headless-pk") @@ -3742,7 +3702,7 @@ def test_cursor_install_uses_cursor_endpoint(self, tmp_path, monkeypatch): ): _run_install(self._args(tmp_path, client="cursor")) - assert send.call_args.args[2:4] == ("cursor", script) + 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") @@ -3766,7 +3726,7 @@ def send(*args): assert install_mock.call_count == 3 assert send_mock.call_count == 1 - assert send_mock.call_args.args[2:4] == ("claude-code", scripts[0]) + 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): diff --git a/tests/unit/test_hook_events.py b/tests/unit/test_hook_events.py new file mode 100644 index 00000000..d35b318b --- /dev/null +++ b/tests/unit/test_hook_events.py @@ -0,0 +1,81 @@ +"""Tests for direct Agent Monitor hook-event delivery.""" + +from __future__ import annotations + +import base64 +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from urllib.error import HTTPError, URLError + +import pytest + +from agent_scan.hook_events import _HOOK_REQUEST_TIMEOUT_SECONDS, send_hook_event +from agent_scan.hook_version import HOOK_VERSION + + +@pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) +def test_sends_existing_hook_wire_contract(client): + response = MagicMock() + response.__enter__.return_value = SimpleNamespace(status=200) + payload = '{"hook_event_name":"serversDiscovered"}' + + with ( + patch("agent_scan.hook_events.get_hostname", return_value="host-1"), + patch("agent_scan.hook_events.get_username", return_value="user-1"), + patch("agent_scan.hook_events.urlopen", return_value=response) as urlopen, + ): + result = send_hook_event("https://api.snyk.io/", client, "push-key", payload, "machine-1") + + assert result == (True, "") + request = urlopen.call_args.args[0] + assert request.full_url == f"https://api.snyk.io/hidden/agent-monitor/hooks/{client}?version={HOOK_VERSION}" + assert urlopen.call_args.kwargs["timeout"] == _HOOK_REQUEST_TIMEOUT_SECONDS + assert base64.b64decode(request.data.decode().removeprefix("base64:")).decode() == payload + assert request.get_header("Content-type") == "text/plain" + assert request.get_header("X-client-id") == "push-key" + assert "Agent Scan v" in request.get_header("User-agent") + assert json.loads(request.get_header("X-user")) == { + "hostname": "host-1", + "username": "user-1", + "identifier": "machine-1", + } + + +def test_machine_identifier_defaults_to_hostname(): + response = MagicMock() + response.__enter__.return_value = SimpleNamespace(status=200) + with ( + patch("agent_scan.hook_events.get_hostname", return_value="host-1"), + patch("agent_scan.hook_events.get_username", return_value="user-1"), + patch("agent_scan.hook_events.urlopen", return_value=response) as urlopen, + ): + result = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}") + + assert result == (True, "") + request = urlopen.call_args.args[0] + assert json.loads(request.get_header("X-user"))["identifier"] == "host-1" + + +@pytest.mark.parametrize( + "error, expected", + [ + (HTTPError("https://api.snyk.io", 403, "Forbidden", None, None), "HTTP 403"), + (URLError("offline"), "offline"), + (TimeoutError("timed out"), "timed out"), + ], +) +def test_reports_http_and_network_failures(error, expected): + with patch("agent_scan.hook_events.urlopen", side_effect=error): + ok, detail = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}") + + assert ok is False + assert expected in detail + + +def test_rejects_unknown_client_without_request(): + with patch("agent_scan.hook_events.urlopen") as urlopen: + result = send_hook_event("https://api.snyk.io", "unknown", "push-key", "{}") + + assert result == (False, "unknown client: unknown") + urlopen.assert_not_called() From 60d87db31bb7e1986b34a41875192f9eaa333127 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Tue, 25 Aug 2026 10:00:45 +0200 Subject: [PATCH 24/58] refactor: separate discovery target folders --- docs/cli-reference.md | 7 ++- src/agent_scan/agents/__init__.py | 12 ++-- src/agent_scan/agents/base.py | 84 ++++++++++++++++++++-------- src/agent_scan/agents/claude_code.py | 4 +- src/agent_scan/agents/codex.py | 4 +- src/agent_scan/agents/opencode.py | 8 +-- src/agent_scan/agents/vscode/base.py | 10 ++-- src/agent_scan/cli.py | 4 +- src/agent_scan/guard.py | 41 +++++++------- src/agent_scan/pipelines.py | 24 ++++---- tests/e2e/test_guard_install.py | 2 +- tests/unit/test_agent_discovery.py | 76 +++++++++++++++++-------- tests/unit/test_guard.py | 48 ++++++++++++---- 13 files changed, 210 insertions(+), 114 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 51535d1f..c9e680a5 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -372,13 +372,14 @@ Installation also configures a fire-and-forget session-start hook that reports d snyk-agent-scan guard discover [OPTIONS] ``` -This internal command is invoked by the SessionStart hook configured by `guard install`. It discovers MCP servers locally -and sends the resulting event directly to Agent Monitor; it is not normally run by hand. +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 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 | — | Hook client whose payload and endpoint conventions should be used. | +| `--client {claude-code,cursor,codex}` | string | required | Hook client whose target-folder payload and endpoint conventions should be used. | ### `guard uninstall` diff --git a/src/agent_scan/agents/__init__.py b/src/agent_scan/agents/__init__.py index 2b161cab..7d23566c 100644 --- a/src/agent_scan/agents/__init__.py +++ b/src/agent_scan/agents/__init__.py @@ -37,18 +37,18 @@ } -def find_discoverers(home_directory: Path | None, project_folders: list[Path] | None = 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, project_folders) + discoverer = cls(home_directory, target_folders) try: exists = discoverer.client_exists() is not None except Exception: diff --git a/src/agent_scan/agents/base.py b/src/agent_scan/agents/base.py index 3e4b619a..2b2a2188 100644 --- a/src/agent_scan/agents/base.py +++ b/src/agent_scan/agents/base.py @@ -145,18 +145,19 @@ class AgentDiscoverer(ABC): name: str = "" - def __init__(self, home_directory: Path | None, project_folders: list[Path] | None = 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() - self.extra_project_folders = list(project_folders or []) - # 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.target_folders = list(target_folders or []) + # Lazily-populated caches for recorded project roots and explicit target + # roots. A discoverer serves a single scan (see find_discoverers), so both + # lists are stable for its lifetime and discovery does not need to re-walk + # workspaceStorage / re-read ~/.claude.json each time. self._project_paths_cache: list[Path] | None = None + self._target_paths_cache: list[Path] | None = None def _scans_own_home(self) -> bool: """True when this discoverer targets the scanning process's own user. @@ -443,7 +444,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. @@ -455,11 +456,22 @@ def _discover_project_folders(self) -> list[Path]: """ return [] - def _all_project_folders(self) -> list[Path]: - """Return recorded roots then explicit roots, deduped without changing their spelling.""" + def _discover_target_folders(self) -> list[Path]: + """Return explicit roots targeted by the current discovery request. + + 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`. + """ + return list(self.target_folders) + + @staticmethod + def _dedupe_folders(folders: Iterator[Path]) -> list[Path]: + """Deduplicate folders by resolved path while preserving spelling and order.""" result: list[Path] = [] seen: set[Path] = set() - for folder in (*self._discover_project_folders(), *self.extra_project_folders): + for folder in folders: try: key = folder.resolve() except OSError: @@ -470,21 +482,25 @@ def _all_project_folders(self) -> list[Path]: result.append(folder) return result - def _project_paths_with_ancestors(self) -> list[Path]: - """Project roots plus every ancestor up to filesystem root, deduplicated. + def _all_project_folders(self) -> list[Path]: + """Return deduplicated roots from the agent's persisted project history.""" + return self._dedupe_folders(iter(self._discover_project_folders())) - 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 _all_target_folders(self) -> list[Path]: + """Return deduplicated roots explicitly targeted by this request.""" + return self._dedupe_folders(iter(self._discover_target_folders())) - The result is cached for the discoverer's lifetime. - """ - if self._project_paths_cache is not None: - return self._project_paths_cache + def _all_discovery_folders(self) -> list[Path]: + """Return project roots then target roots, deduplicated across both sets.""" + return self._dedupe_folders(iter((*self._all_project_folders(), *self._all_target_folders()))) + + @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._all_project_folders(): - cur = project_path + for folder in folders: + cur = folder while True: if cur not in seen: seen.add(cur) @@ -493,5 +509,29 @@ def _project_paths_with_ancestors(self) -> list[Path]: if parent == cur: break cur = parent - self._project_paths_cache = result return result + + 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). + + The result is cached for the discoverer's lifetime. + """ + if self._project_paths_cache is not None: + return self._project_paths_cache + self._project_paths_cache = self._folders_with_ancestors(self._all_project_folders()) + return self._project_paths_cache + + def _target_paths_with_ancestors(self) -> list[Path]: + """Target roots plus every ancestor up to filesystem root, deduplicated.""" + if self._target_paths_cache is not None: + return self._target_paths_cache + self._target_paths_cache = self._folders_with_ancestors(self._all_target_folders()) + return self._target_paths_cache + + def _discovery_paths_with_ancestors(self) -> list[Path]: + """Return project and target paths with ancestors, deduplicated across both.""" + return self._dedupe_folders(iter((*self._project_paths_with_ancestors(), *self._target_paths_with_ancestors()))) diff --git a/src/agent_scan/agents/claude_code.py b/src/agent_scan/agents/claude_code.py index 89c4ddb5..72034d87 100644 --- a/src/agent_scan/agents/claude_code.py +++ b/src/agent_scan/agents/claude_code.py @@ -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 2516cafd..4d227d30 100644 --- a/src/agent_scan/agents/opencode.py +++ b/src/agent_scan/agents/opencode.py @@ -501,7 +501,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(): result.update(self._scan_config_dir(project)) return result @@ -549,7 +549,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 @@ -577,7 +577,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 filename in _CONFIG_FILENAMES: candidates.append(project / filename) managed = self._managed_config_dir() @@ -613,7 +613,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._all_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/base.py b/src/agent_scan/agents/vscode/base.py index 152bd50c..f298133c 100644 --- a/src/agent_scan/agents/vscode/base.py +++ b/src/agent_scan/agents/vscode/base.py @@ -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 7cd469ae..8ec31081 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -1030,9 +1030,9 @@ def main(): "--client", type=str, choices=["claude-code", "cursor", "codex"], - default=None, + required=True, metavar="CLIENT", - help=("Read the selected agent's hook JSON payload from stdin and include its project folders in discovery"), + help=("Required; read the selected agent's hook JSON payload from stdin and include its target folders"), ) guard_uninstall_parser = guard_subparsers.add_parser( "uninstall", diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 5c98c5cf..4334ab9d 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -42,7 +42,7 @@ # --------------------------------------------------------------------------- ALL_CLIENTS = ["claude", "cursor", "codex"] -_HOOK_CLIENT_PROJECT_FOLDER_FIELDS = { +_HOOK_CLIENT_TARGET_FOLDER_FIELDS = { "claude-code": "cwd", "cursor": "workspace_roots", "codex": "cwd", @@ -367,24 +367,27 @@ def _run_discover(args) -> int: url = getattr(args, "url", None) or os.environ.get("REMOTE_HOOKS_BASE_URL") or DEFAULT_REMOTE_URL machine_id = (os.environ.get("MACHINE_ID", "") or "").strip() + 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 - project_folders: list[str] = [] + target_folders: list[str] = [] session_id = "" - hook_client = getattr(args, "client", None) - project_folder_payload_field = _HOOK_CLIENT_PROJECT_FOLDER_FIELDS.get(hook_client) if hook_client else None - session_payload_field = _HOOK_CLIENT_SESSION_FIELDS.get(hook_client) if hook_client else None - if project_folder_payload_field or session_payload_field: + target_folder_payload_field = _HOOK_CLIENT_TARGET_FOLDER_FIELDS.get(hook_client) + session_payload_field = _HOOK_CLIENT_SESSION_FIELDS.get(hook_client) + if target_folder_payload_field or session_payload_field: try: hook_payload = json.loads(_read_hook_payload()) - project_folder = ( - hook_payload.get(project_folder_payload_field) - if isinstance(hook_payload, dict) and project_folder_payload_field + target_folder = ( + hook_payload.get(target_folder_payload_field) + if isinstance(hook_payload, dict) and target_folder_payload_field else None ) - if isinstance(project_folder, str) and project_folder: - project_folders.append(project_folder) - elif isinstance(project_folder, list): - project_folders.extend(folder for folder in project_folder if isinstance(folder, str) and folder) + 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(session_payload_field) if isinstance(hook_payload, dict) and session_payload_field @@ -398,11 +401,11 @@ def _run_discover(args) -> int: success = _send_servers_discovered_event( push_key, url, - hook_client or "claude-code", + hook_client, machine_id, event_name="SessionStartServerDiscovery", session_marker=session_id or "session-start-server-discovery", - project_folders=project_folders, + target_folders=target_folders, ) return 0 if success else 1 @@ -1185,7 +1188,7 @@ def _servers_discovered_entries(clients_to_inspect: list[ClientToInspect]) -> li return [request.model_dump(mode="json") for request in build_scan_request(inspected_paths).scan_path_requests] -def _discover_servers_payload(project_folders: list[str] | None = None) -> list[dict]: +def _discover_servers_payload(target_folders: list[str] | None = None) -> list[dict]: import asyncio from agent_scan import pipelines @@ -1195,7 +1198,7 @@ def _discover_servers_payload(project_folders: list[str] | None = None) -> list[ timeout=0, tokens=[], paths=[], - project_folders=project_folders or [], + target_folders=target_folders or [], ) clients_to_inspect, _, _ = _run_with_timeout( lambda: asyncio.run(pipelines.discover_clients_to_inspect(inspect_args)), @@ -1317,12 +1320,12 @@ def _send_servers_discovered_event( *, event_name: str = "serversDiscovered", session_marker: str = "hooks-setup", - project_folders: list[str] | None = None, + target_folders: list[str] | None = None, ) -> bool: rich.print("[dim]Discovering MCP servers...[/dim]") started = time.monotonic() try: - servers = _discover_servers_payload(project_folders) + servers = _discover_servers_payload(target_folders) except Exception as e: rich.print(f"[yellow]Warning:[/yellow] Could not discover MCP servers: {e}") return False diff --git a/src/agent_scan/pipelines.py b/src/agent_scan/pipelines.py index 1cda37bb..35c6b6db 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -35,7 +35,7 @@ class InspectArgs(BaseModel): paths: list[str] all_users: bool = False scan_skills: bool = False - project_folders: list[str] = Field(default_factory=list) + target_folders: list[str] = Field(default_factory=list) class AnalyzeArgs(BaseModel): @@ -86,21 +86,21 @@ async def discover_clients_to_inspect( ) ) else: - project_folders: list[Path] = [] - seen_project_folders: set[Path] = set() - for raw_path in inspect_args.project_folders: - project_path = Path(raw_path).expanduser() + 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 = project_path.resolve() + key = target_path.resolve() except OSError: - key = project_path - if key in seen_project_folders: + key = target_path + if key in seen_target_folders: continue - seen_project_folders.add(key) + seen_target_folders.add(key) if not key.exists(): - logger.warning("Skipping non-existent project folder: %s", project_path) + logger.warning("Skipping non-existent target folder: %s", target_path) continue - project_folders.append(project_path) + 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(): @@ -112,7 +112,7 @@ 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, project_folders=project_folders): + for discoverer in find_discoverers(home_directory, target_folders=target_folders): try: cti = discoverer.discover() except Exception: diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index 4448f11f..c0b8c6db 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -103,7 +103,7 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): assert json.loads(discovered["headers"]["X-User"])["identifier"] == "e2e-machine-id" discover_result = subprocess.run( - [*agent_scan_cmd, "guard", "discover"], + [*agent_scan_cmd, "guard", "discover", "--client", "claude-code"], capture_output=True, text=True, timeout=60, diff --git a/tests/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index 429eb6fa..1c27b8fb 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -8847,10 +8847,10 @@ def flaky_exists(self, *args, **kwargs): assert result.endswith("/.opencode") -# --- Explicit project-folder injection --- +# --- Explicit target-folder injection --- -def test_all_project_folders_puts_agent_roots_before_explicit_roots(tmp_path): +def test_project_and_target_folders_remain_separate(tmp_path): from agent_scan.agents import ClaudeCodeDiscoverer recorded = tmp_path / "recorded" @@ -8859,24 +8859,32 @@ def test_all_project_folders_puts_agent_roots_before_explicit_roots(tmp_path): discoverer = ClaudeCodeDiscoverer(tmp_path, [explicit]) - assert discoverer._all_project_folders() == [recorded, explicit] + assert discoverer._all_project_folders() == [recorded] + assert discoverer._discover_target_folders() == [explicit] + assert discoverer._all_target_folders() == [explicit] + assert discoverer._all_discovery_folders() == [recorded, explicit] -def test_explicit_project_folders_gain_ancestors_and_dedup_recorded_roots(tmp_path): +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()}": {{}}}}}}') - paths = ClaudeCodeDiscoverer(tmp_path, [project])._project_paths_with_ancestors() + discoverer = ClaudeCodeDiscoverer(tmp_path, [project]) + project_paths = discoverer._project_paths_with_ancestors() + target_paths = discoverer._target_paths_with_ancestors() + paths = discoverer._discovery_paths_with_ancestors() assert paths.count(project) == 1 + assert project in project_paths + assert project in target_paths assert project.parent in paths assert tmp_path in paths @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") -def test_all_project_folders_dedupes_resolved_paths_and_keeps_recorded_spelling(tmp_path): +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" @@ -8888,12 +8896,14 @@ def test_all_project_folders_dedupes_resolved_paths_and_keeps_recorded_spelling( discoverer = ClaudeCodeDiscoverer(tmp_path, [target]) assert discoverer._all_project_folders() == [recorded_link] - paths = discoverer._project_paths_with_ancestors() + assert discoverer._all_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_claude_code_discovers_servers_and_skills_from_explicit_project_without_state_entry(tmp_path): +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() @@ -8914,7 +8924,25 @@ def test_claude_code_discovers_servers_and_skills_from_explicit_project_without_ assert (project / ".agents" / "skills").as_posix() in skills -def test_codex_discovers_servers_and_skills_from_explicit_project(tmp_path): +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() @@ -8933,7 +8961,7 @@ def test_codex_discovers_servers_and_skills_from_explicit_project(tmp_path): assert (project / ".agents" / "skills").as_posix() in skills -def test_cursor_discovers_servers_and_skills_from_explicit_project_without_workspace_state(tmp_path): +def test_cursor_discovers_servers_and_skills_from_target_without_workspace_state(tmp_path): from agent_scan.agents import CursorDiscoverer (tmp_path / ".cursor").mkdir() @@ -8952,7 +8980,7 @@ def test_cursor_discovers_servers_and_skills_from_explicit_project_without_works assert (project / ".cursor" / "skills").as_posix() in skills -def test_opencode_relative_skills_path_anchors_at_explicit_project_root(tmp_path): +def test_opencode_relative_skills_path_anchors_at_target_root(tmp_path): from agent_scan.agents import OpenCodeDiscoverer _opencode_install(tmp_path) @@ -8966,20 +8994,20 @@ def test_opencode_relative_skills_path_anchors_at_explicit_project_root(tmp_path assert (project / "team-skills").as_posix() in skills -def test_find_discoverers_threads_explicit_project_folders(tmp_path): +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, project_folders=[project]) + found = find_discoverers(tmp_path, target_folders=[project]) claude = next(discoverer for discoverer in found if isinstance(discoverer, ClaudeCodeDiscoverer)) - assert claude.extra_project_folders == [project] + assert claude.target_folders == [project] @pytest.mark.asyncio -async def test_pipeline_merges_explicit_project_servers_and_skills_into_installed_client(tmp_path): +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" @@ -8994,7 +9022,7 @@ async def test_pipeline_merges_explicit_project_servers_and_skills_into_installe patch("agent_scan.pipelines.get_well_known_clients", return_value=[]), ): clients, _, _ = await discover_clients_to_inspect( - InspectArgs(timeout=0, tokens=[], paths=[], scan_skills=True, project_folders=[str(project)]) + InspectArgs(timeout=0, tokens=[], paths=[], scan_skills=True, target_folders=[str(project)]) ) claude = next(client for client in clients if client.name == "claude code") @@ -9006,7 +9034,7 @@ async def test_pipeline_merges_explicit_project_servers_and_skills_into_installe @pytest.mark.asyncio @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics") -async def test_pipeline_preserves_unresolved_project_folder_spelling(tmp_path): +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" @@ -9022,14 +9050,14 @@ async def test_pipeline_preserves_unresolved_project_folder_spelling(tmp_path): patch("agent_scan.pipelines.find_discoverers", return_value=[]) as find, ): await discover_clients_to_inspect( - InspectArgs(timeout=0, tokens=[], paths=[], project_folders=[str(project_link)]) + InspectArgs(timeout=0, tokens=[], paths=[], target_folders=[str(project_link)]) ) - find.assert_called_once_with(home, project_folders=[project_link]) + find.assert_called_once_with(home, target_folders=[project_link]) @pytest.mark.asyncio -async def test_pipeline_skips_missing_explicit_project_folder_with_warning(tmp_path, caplog): +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" @@ -9041,14 +9069,14 @@ async def test_pipeline_skips_missing_explicit_project_folder_with_warning(tmp_p 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=[], project_folders=[str(missing)])) + 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_explicit_paths_ignore_project_folders(tmp_path): +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 @@ -9062,9 +9090,9 @@ async def test_pipeline_explicit_paths_ignore_project_folders(tmp_path): timeout=0, tokens=[], paths=[str(explicit_config)], - project_folders=[str(project)], + target_folders=[str(project)], ) - assert inspect_args.project_folders == [str(project)] + assert inspect_args.target_folders == [str(project)] with ( patch("agent_scan.pipelines.get_readable_home_directories", return_value=[]), diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index fd792b5c..f7d40f9a 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -387,7 +387,7 @@ class TestBuildDiscoverHookCommand: [("claude-code", "cwd"), ("cursor", "workspace_roots"), ("codex", "cwd")], ) def test_client_payload_fields_match_hook_schemas(self, client, expected_field): - assert guard_module._HOOK_CLIENT_PROJECT_FOLDER_FIELDS[client] == expected_field + assert guard_module._HOOK_CLIENT_TARGET_FOLDER_FIELDS[client] == expected_field @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): @@ -3056,14 +3056,14 @@ def test_uses_current_user_server_only_discovery(self): assert args.scan_skills is False assert result == guard_module._servers_discovered_entries(clients) - def test_threads_explicit_project_folders_to_inspect_args(self): + 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.project_folders == ["/repo/one", "/repo/two"] + assert args.target_folders == ["/repo/one", "/repo/two"] assert result == [] @pytest.mark.parametrize( @@ -3306,7 +3306,15 @@ def test_parses_url(self, monkeypatch): monkeypatch.setattr( sys, "argv", - ["agent-scan", "guard", "discover", "--url", "https://hooks.example"], + [ + "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: @@ -3321,7 +3329,20 @@ def test_parses_url(self, monkeypatch): def test_rejects_removed_file_option(self, monkeypatch): from agent_scan import cli - monkeypatch.setattr(sys, "argv", ["agent-scan", "guard", "discover", "--file", "/tmp/settings.json"]) + 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() @@ -3361,7 +3382,7 @@ def _args(config: Path, url=None, **overrides): values = { "guard_command": "discover", "url": url, - "client": None, + "client": "claude-code", } values.update(overrides) return SimpleNamespace(**values) @@ -3624,7 +3645,7 @@ def test_hook_stdin_forwards_valid_session_marker_or_falls_back( event_payload = json.loads(send.call_args.args[3]) assert event_payload[event_session_field] == expected_marker - def test_stdin_is_never_read_without_client(self, tmp_path, monkeypatch): + 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() @@ -3632,14 +3653,17 @@ def test_stdin_is_never_read_without_client(self, tmp_path, monkeypatch): stdin.read.side_effect = AssertionError("stdin must not be read") with ( patch.object(sys, "stdin", stdin), - patch(f"{_G}._discover_servers_payload", return_value=[]), - patch(f"{_G}.send_hook_event", return_value=(True, "")), - patch(f"{_G}.rich"), + 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)) + result = guard_module.run_guard(self._args(config, client=None)) - assert result == 0 + 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" From d51411f66d1b0bb4724caaaabb0e5faa9234ddfa Mon Sep 17 00:00:00 2001 From: iamcristi Date: Tue, 25 Aug 2026 10:55:47 +0200 Subject: [PATCH 25/58] fix: require machine ID for Agent Guard hooks --- docs/cli-reference.md | 8 +- src/agent_scan/cli.py | 2 +- src/agent_scan/guard.py | 17 +++- src/agent_scan/hook_events.py | 6 +- .../hooks/snyk-agent-guard-discover.ps1 | 4 +- .../hooks/snyk-agent-guard-discover.sh | 4 +- src/agent_scan/hooks/snyk-agent-guard.ps1 | 7 +- src/agent_scan/hooks/snyk-agent-guard.sh | 3 +- tests/e2e/test_guard_install.py | 18 ++++- tests/unit/test_guard.py | 81 ++++++++++++++----- tests/unit/test_hook_events.py | 21 ++--- 11 files changed, 119 insertions(+), 52 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c9e680a5..087ff25a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -361,7 +361,7 @@ Installation also configures a fire-and-forget session-start hook that reports d | --- | --- | --- | --- | | `--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`, `--control-identifier ID` | string | — | Non-anonymous machine identifier sent in the `X-User` header's `identifier` field. | +| `--machine-id ID`, `--control-identifier 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.** | @@ -399,7 +399,7 @@ 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` | Non-anonymous machine identifier sent with hook events | +| `MACHINE_ID` | Required non-anonymous machine identifier sent with hook events; alternative to `guard install --machine-id` | | `AGENT_SCAN_BIN` | Agent Scan executable used by the session-start discovery trampoline | | `AGENT_SCAN_DISCOVERY_TIMEOUT_SECONDS` | Discovery timeout in seconds (default: `60`) | @@ -523,10 +523,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/cli.py b/src/agent_scan/cli.py index 8ec31081..6f8d45a2 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -989,7 +989,7 @@ def main(): default=None, metavar="ID", help=( - "Non-anonymous identifier for this machine, sent as the X-User identifier on hook events " + "Required non-anonymous identifier for this machine, sent as the X-User identifier on hook events " "(accepts --control-identifier for symmetry with scan)" ), ) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 4334ab9d..13919879 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -210,6 +210,9 @@ def _run_install(args) -> None: 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: + rich.print("[bold red]Error:[/bold red] --machine-id is required (or set the MACHINE_ID environment variable).") + sys.exit(1) clients = ALL_CLIENTS if client == "all" else [client] @@ -366,11 +369,14 @@ def _run_discover(args) -> int: return 1 url = getattr(args, "url", None) or os.environ.get("REMOTE_HOOKS_BASE_URL") or DEFAULT_REMOTE_URL - machine_id = (os.environ.get("MACHINE_ID", "") or "").strip() 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 = "" @@ -1040,13 +1046,16 @@ def _run_status() -> None: 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]" diff --git a/src/agent_scan/hook_events.py b/src/agent_scan/hook_events.py index 22024f7f..e6b5c79d 100644 --- a/src/agent_scan/hook_events.py +++ b/src/agent_scan/hook_events.py @@ -25,19 +25,21 @@ def send_hook_event( hook_client: str, push_key: str, payload: str, - machine_id: str = "", + machine_id: str, ) -> tuple[bool, str]: """POST a hook event using the same wire contract as the hook scripts.""" endpoint = _HOOK_ENDPOINTS.get(hook_client) if endpoint 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 or hostname, + "identifier": machine_id, }, separators=(",", ":"), ) diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 index d6daa096..d9c978f9 100644 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -25,7 +25,9 @@ $ErrorActionPreference = "Stop" if ($PushKey) { $env:PUSH_KEY = $PushKey } if ($RemoteUrl) { $env:REMOTE_HOOKS_BASE_URL = $RemoteUrl } -if ($MachineId) { $env:MACHINE_ID = $MachineId } +if (-not $MachineId) { $MachineId = $env:MACHINE_ID } +if (-not $MachineId) { exit 1 } +$env:MACHINE_ID = $MachineId $bin = if ($AgentScanBin) { $AgentScanBin } elseif ($env:AGENT_SCAN_BIN) { $env:AGENT_SCAN_BIN } else { "snyk-agent-scan" } if (-not (Get-Command $bin -ErrorAction SilentlyContinue)) { $bin = "snyk-agent-scan" } diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh index 3981d6da..009fd763 100755 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.sh +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -1,8 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -# The path baked in at install time can go stale: a uvx install resolves to an -# absolute path under ~/.cache/uv that uv later garbage-collects. Fall back to -# PATH rather than exec'ing a binary that is no longer there. +[[ -n "${MACHINE_ID:-}" ]] || exit 1 bin="${AGENT_SCAN_BIN:-snyk-agent-scan}" if ! command -v "$bin" >/dev/null 2>&1; then bin="snyk-agent-scan" diff --git a/src/agent_scan/hooks/snyk-agent-guard.ps1 b/src/agent_scan/hooks/snyk-agent-guard.ps1 index 125201ab..326a7972 100644 --- a/src/agent_scan/hooks/snyk-agent-guard.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard.ps1 @@ -50,6 +50,10 @@ if (-not $RemoteUrl) { } 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" { @@ -93,9 +97,8 @@ function JsonEscape($s) { return $s } -$identifier = if ($MachineId) { $MachineId } else { $hostname } $xUser = '{{"hostname":"{0}","username":"{1}","identifier":"{2}"}}' -f ` - (JsonEscape $hostname), (JsonEscape $username), (JsonEscape $identifier) + (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 799bfa48..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 "${MACHINE_ID:-$hostname}")")" + "\"identifier\"" "$(json_quote "$MACHINE_ID")")" # Execute request local resp body http_code marker diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index c0b8c6db..5f6a349c 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -137,6 +137,8 @@ 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, @@ -164,7 +166,12 @@ def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): capture_output=True, text=True, timeout=60, - env={**os.environ, "PUSH_KEY": "test-pk-e2e", "REMOTE_HOOKS_BASE_URL": fake_hook_server}, + 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}" @@ -190,6 +197,8 @@ def test_guard_install_codex(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, @@ -219,7 +228,12 @@ def test_guard_install_codex(self, agent_scan_cmd, tmp_path, fake_hook_server): capture_output=True, text=True, timeout=60, - env={**os.environ, "PUSH_KEY": "test-pk-e2e", "REMOTE_HOOKS_BASE_URL": fake_hook_server}, + 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}" diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index f7d40f9a..f4121a03 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -654,9 +654,7 @@ def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_pat assert discover_script.read_text() == ( "#!/usr/bin/env bash\nset -euo pipefail\n" - "# The path baked in at install time can go stale: a uvx install resolves to an\n" - "# absolute path under ~/.cache/uv that uv later garbage-collects. Fall back to\n" - "# PATH rather than exec'ing a binary that is no longer there.\n" + '[[ -n "${MACHINE_ID:-}" ]] || exit 1\n' 'bin="${AGENT_SCAN_BIN:-snyk-agent-scan}"\n' 'if ! command -v "$bin" >/dev/null 2>&1; then\n' ' bin="snyk-agent-scan"\n' @@ -692,6 +690,7 @@ def test_stale_absolute_binary_falls_back_to_path(self, tmp_path): env = { **os.environ, "AGENT_SCAN_BIN": str(tmp_path / "deleted" / "snyk-agent-scan"), + "MACHINE_ID": "machine-42", "MARKER": str(marker), "PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", } @@ -1747,6 +1746,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 @@ -1778,6 +1778,7 @@ def test_posts_large_payload_without_exec_argument_limit(self, hook_server): "PATH": "/usr/bin:/bin:/usr/local/bin", "PUSH_KEY": "test-pk-large-payload", "REMOTE_HOOKS_BASE_URL": hook_server, + "MACHINE_ID": "machine-42", }, ) @@ -1800,6 +1801,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 @@ -1818,6 +1820,7 @@ 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 @@ -1842,7 +1845,7 @@ def test_machine_id_sets_x_user_identifier(self, hook_server): x_user = json.loads(_HookHandler.last_request["headers"]["X-User"]) assert x_user["identifier"] == "machine-42" - def test_x_user_identifier_falls_back_to_hostname(self, hook_server): + 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"], @@ -1854,12 +1857,10 @@ def test_x_user_identifier_falls_back_to_hostname(self, hook_server): "PATH": "/usr/bin:/bin:/usr/local/bin", "PUSH_KEY": "test-pk", "REMOTE_HOOKS_BASE_URL": hook_server, - "HOSTNAME": "fallback-host", }, ) - assert result.returncode == 0, result.stderr - x_user = json.loads(_HookHandler.last_request["headers"]["X-User"]) - assert x_user["identifier"] == "fallback-host" + 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") @@ -1888,6 +1889,7 @@ 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 @@ -1921,6 +1923,8 @@ def test_posts_base64_payload(self, hook_server): "test-pk-123", "-RemoteUrl", hook_server, + "-MachineId", + "machine-42", ], input=payload, capture_output=True, @@ -1951,6 +1955,8 @@ def test_cursor_endpoint(self, hook_server): "test-pk-456", "-RemoteUrl", hook_server, + "-MachineId", + "machine-42", ], input=payload, capture_output=True, @@ -1985,7 +1991,7 @@ def test_machine_id_sets_x_user_identifier(self, hook_server): x_user = json.loads(_HookHandler.last_request["headers"]["X-User"]) assert x_user["identifier"] == "machine-42" - def test_x_user_identifier_falls_back_to_hostname(self, hook_server): + 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) @@ -2007,9 +2013,8 @@ def test_x_user_identifier_falls_back_to_hostname(self, hook_server): timeout=15, env=env, ) - assert result.returncode == 0, result.stderr - x_user = json.loads(_HookHandler.last_request["headers"]["X-User"]) - assert x_user["identifier"] == x_user["hostname"] + 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") @@ -2092,6 +2097,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( @@ -2186,7 +2192,10 @@ class TestRunInstallCallsEnsureGuardEnabled: 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): + 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") @@ -3374,7 +3383,7 @@ def test_parses_discovery_client(self, agent, monkeypatch): class TestRunDiscover: @pytest.fixture(autouse=True) def _posix_mode(self): - with patch(f"{_G}.IS_WINDOWS", False): + with patch(f"{_G}.IS_WINDOWS", False), patch.dict(os.environ, {"MACHINE_ID": "machine-42"}): yield @staticmethod @@ -3448,6 +3457,22 @@ def test_missing_push_key_returns_one_without_invoking_script(self, tmp_path, mo 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 ( @@ -3685,7 +3710,7 @@ def test_windows_uses_direct_sender(self, tmp_path, monkeypatch): class TestRunInstallSendsServersDiscovered: @staticmethod - def _args(tmp_path, *, client="claude", file_override=True, managed=False, machine_id=None): + def _args(tmp_path, *, client="claude", file_override=True, managed=False, machine_id="machine-42"): return SimpleNamespace( client=client, url="https://api.snyk.io", @@ -3797,7 +3822,7 @@ def test_send_failure_keeps_success_exit_and_does_not_revoke(self, tmp_path, mon @pytest.mark.parametrize( "arg_machine_id, env_machine_id, expected", - [("args-id", "env-id", "args-id"), (None, "env-id", "env-id"), (None, None, "")], + [("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 @@ -3816,6 +3841,20 @@ def test_machine_id_precedence_reaches_install_and_send( assert install.call_args.args[-1] == expected assert send.call_args.args[-1] == expected + def test_missing_machine_id_aborts_before_install(self, tmp_path, monkeypatch): + monkeypatch.setenv("PUSH_KEY", "headless-pk") + monkeypatch.delenv("MACHINE_ID", raising=False) + with ( + patch(f"{_G}._install_hooks") as install, + patch(f"{_G}._send_servers_discovered_event") as send, + pytest.raises(SystemExit) as exc, + ): + _run_install(self._args(tmp_path, machine_id=None)) + + assert exc.value.code == 1 + install.assert_not_called() + send.assert_not_called() + def test_managed_install_sends(self, tmp_path, monkeypatch): monkeypatch.setenv("PUSH_KEY", "headless-pk") with ( @@ -4347,7 +4386,10 @@ def _all_clients_installed(self, tmp_path): 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): + 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") @@ -4539,7 +4581,10 @@ class TestRunInstallSkipsUninstalledClients: 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): + with ( + patch("agent_scan.guard._send_servers_discovered_event", return_value=True), + patch.dict(os.environ, {"MACHINE_ID": "machine-42"}), + ): yield @staticmethod diff --git a/tests/unit/test_hook_events.py b/tests/unit/test_hook_events.py index d35b318b..71ad36ce 100644 --- a/tests/unit/test_hook_events.py +++ b/tests/unit/test_hook_events.py @@ -42,19 +42,12 @@ def test_sends_existing_hook_wire_contract(client): } -def test_machine_identifier_defaults_to_hostname(): - response = MagicMock() - response.__enter__.return_value = SimpleNamespace(status=200) - with ( - patch("agent_scan.hook_events.get_hostname", return_value="host-1"), - patch("agent_scan.hook_events.get_username", return_value="user-1"), - patch("agent_scan.hook_events.urlopen", return_value=response) as urlopen, - ): - result = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}") +def test_rejects_missing_machine_identifier_without_request(): + with patch("agent_scan.hook_events.urlopen") as urlopen: + result = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", " ") - assert result == (True, "") - request = urlopen.call_args.args[0] - assert json.loads(request.get_header("X-user"))["identifier"] == "host-1" + assert result == (False, "machine ID is required") + urlopen.assert_not_called() @pytest.mark.parametrize( @@ -67,7 +60,7 @@ def test_machine_identifier_defaults_to_hostname(): ) def test_reports_http_and_network_failures(error, expected): with patch("agent_scan.hook_events.urlopen", side_effect=error): - ok, detail = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}") + ok, detail = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", "machine-1") assert ok is False assert expected in detail @@ -75,7 +68,7 @@ def test_reports_http_and_network_failures(error, expected): def test_rejects_unknown_client_without_request(): with patch("agent_scan.hook_events.urlopen") as urlopen: - result = send_hook_event("https://api.snyk.io", "unknown", "push-key", "{}") + result = send_hook_event("https://api.snyk.io", "unknown", "push-key", "{}", "machine-1") assert result == (False, "unknown client: unknown") urlopen.assert_not_called() From 4cbf8f3037cd6b76a9904e512cd62c02c44030b2 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Tue, 25 Aug 2026 12:31:31 +0200 Subject: [PATCH 26/58] fix: harden session-start server discovery --- src/agent_scan/agents/__init__.py | 3 +- src/agent_scan/agents/base.py | 46 +++-- src/agent_scan/cli.py | 13 +- src/agent_scan/guard.py | 143 ++++++++-------- src/agent_scan/hook_events.py | 28 +-- .../hooks/snyk-agent-guard-discover.ps1 | 18 +- .../hooks/snyk-agent-guard-discover.sh | 5 +- src/agent_scan/pipelines.py | 21 ++- src/agent_scan/utils.py | 8 + tests/unit/test_agent_discovery.py | 125 +++++++++++++- tests/unit/test_cli_config_file.py | 6 + tests/unit/test_guard.py | 160 +++++++++++++++--- tests/unit/test_hook_events.py | 12 +- 13 files changed, 443 insertions(+), 145 deletions(-) diff --git a/src/agent_scan/agents/__init__.py b/src/agent_scan/agents/__init__.py index 7d23566c..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 @@ -67,6 +67,7 @@ def find_discoverers(home_directory: Path | None, target_folders: list[Path] | N "ClaudeDesktopDiscoverer", "CodexDiscoverer", "CursorDiscoverer", + "DiscoveryScope", "KiroDiscoverer", "OpenCodeDiscoverer", "VSCodeDiscoverer", diff --git a/src/agent_scan/agents/base.py b/src/agent_scan/agents/base.py index 2b2a2188..2c86540e 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 @@ -31,6 +32,7 @@ ) from agent_scan.signed_binary import check_server_signature from agent_scan.skill_client import inspect_skills_dir +from agent_scan.utils import safe_resolve logger = logging.getLogger(__name__) McpConfigsResult = dict[ @@ -43,6 +45,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 @@ -158,6 +167,7 @@ def __init__(self, home_directory: Path | None, target_folders: list[Path] | Non # workspaceStorage / re-read ~/.claude.json each time. self._project_paths_cache: list[Path] | None = None self._target_paths_cache: list[Path] | None = None + 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. @@ -184,11 +194,8 @@ def _scans_own_home(self) -> bool: pass if self.home_directory in candidates: return True - try: - resolved_home = self.home_directory.resolve() - return any(resolved_home == candidate.resolve() for candidate in candidates) - except OSError: - return False + resolved_home = safe_resolve(self.home_directory) + return any(resolved_home == safe_resolve(candidate) for candidate in candidates) def __init_subclass__(cls, *, abstract: bool = False, **kwargs: object) -> None: """Enforce a non-empty ``name`` on concrete subclasses. @@ -216,13 +223,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, @@ -468,17 +476,13 @@ def _discover_target_folders(self) -> list[Path]: @staticmethod def _dedupe_folders(folders: Iterator[Path]) -> list[Path]: - """Deduplicate folders by resolved path while preserving spelling and order.""" + """Deduplicate folders by literal path while preserving spelling and order.""" result: list[Path] = [] seen: set[Path] = set() for folder in folders: - try: - key = folder.resolve() - except OSError: - key = folder - if key in seen: + if folder in seen: continue - seen.add(key) + seen.add(folder) result.append(folder) return result @@ -491,8 +495,11 @@ def _all_target_folders(self) -> list[Path]: return self._dedupe_folders(iter(self._discover_target_folders())) def _all_discovery_folders(self) -> list[Path]: - """Return project roots then target roots, deduplicated across both sets.""" - return self._dedupe_folders(iter((*self._all_project_folders(), *self._all_target_folders()))) + """Return project roots and non-alias target roots in stable literal order.""" + projects = self._all_project_folders() + resolved_projects = {safe_resolve(project) for project in projects} + targets = [target for target in self._all_target_folders() if safe_resolve(target) not in resolved_projects] + return self._dedupe_folders(iter((*projects, *targets))) @staticmethod def _folders_with_ancestors(folders: list[Path]) -> list[Path]: @@ -534,4 +541,7 @@ def _target_paths_with_ancestors(self) -> list[Path]: def _discovery_paths_with_ancestors(self) -> list[Path]: """Return project and target paths with ancestors, deduplicated across both.""" - return self._dedupe_folders(iter((*self._project_paths_with_ancestors(), *self._target_paths_with_ancestors()))) + 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/cli.py b/src/agent_scan/cli.py index 6f8d45a2..3d8b8f3b 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, @@ -255,8 +256,7 @@ def _iter_active_actions(parser: argparse.ArgumentParser, argv: list[str]): while index < len(argv): token = argv[index] if token == "--": - index += 1 - continue + return if token.startswith("-") and token != "-": index += 1 if "=" in token else 1 + values_consumed.get(token, 0) continue @@ -284,6 +284,8 @@ def explicitly_provided_dests(parser: argparse.ArgumentParser, argv: list[str]) provided: set[str] = set() for token in argv: + if token == "--": + break option = token.split("=", 1)[0] dest = option_to_dest.get(option) if dest is not None: @@ -1034,6 +1036,12 @@ def main(): 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, @@ -1215,6 +1223,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 13919879..f7e2cbb2 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -11,6 +11,7 @@ import shutil import stat import sys +import threading import time from importlib import resources as importlib_resources from pathlib import Path @@ -19,7 +20,8 @@ import rich -from agent_scan.hook_events import send_hook_event +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, @@ -42,16 +44,6 @@ # --------------------------------------------------------------------------- ALL_CLIENTS = ["claude", "cursor", "codex"] -_HOOK_CLIENT_TARGET_FOLDER_FIELDS = { - "claude-code": "cwd", - "cursor": "workspace_roots", - "codex": "cwd", -} -_HOOK_CLIENT_SESSION_FIELDS = { - "claude-code": "session_id", - "cursor": "conversation_id", - "codex": "session_id", -} DEFAULT_REMOTE_URL = "https://api.snyk.io" _DETECTION_RE = re.compile( r"PUSH_KEY=.*snyk-agent-guard" @@ -279,7 +271,6 @@ 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: @@ -298,10 +289,9 @@ def _run_install(args) -> None: ) if first_installed_client is None: first_installed_client = _hook_client_name(c) - installed_any = True 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. " @@ -315,10 +305,12 @@ def _run_install(args) -> None: _send_servers_discovered_event(push_key, url, first_installed_client, machine_id) -def _run_with_timeout(func: Callable[[], _T], timeout: float) -> _T: - """Run ``func`` on a daemon thread; raise ``TimeoutError`` if it outlives ``timeout``.""" - import threading - +def _run_with_timeout( + func: Callable[[], _T], + timeout: float, + cancel: threading.Event | None = None, +) -> _T: + """Run ``func`` on a daemon thread and signal cooperative cancellation on timeout.""" result: list[_T] = [] error: list[BaseException] = [] @@ -332,6 +324,9 @@ def run() -> None: thread.start() thread.join(timeout) if thread.is_alive(): + if cancel is not None: + cancel.set() + thread.join(0.1) raise TimeoutError(f"timed out after {timeout:g}s") if error: raise error[0] @@ -352,7 +347,7 @@ def _discovery_timeout_seconds() -> float: def _read_hook_payload() -> str: - """Read hook JSON from stdin without allowing an open stream to hang discovery.""" + """Read hook JSON with a timeout; a blocked stdin read cannot be cancelled cooperatively.""" stream = sys.stdin try: if stream is None or stream.isatty(): @@ -380,29 +375,19 @@ def _run_discover(args) -> int: target_folders: list[str] = [] session_id = "" - target_folder_payload_field = _HOOK_CLIENT_TARGET_FOLDER_FIELDS.get(hook_client) - session_payload_field = _HOOK_CLIENT_SESSION_FIELDS.get(hook_client) - if target_folder_payload_field or session_payload_field: - try: - hook_payload = json.loads(_read_hook_payload()) - target_folder = ( - hook_payload.get(target_folder_payload_field) - if isinstance(hook_payload, dict) and target_folder_payload_field - 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(session_payload_field) - if isinstance(hook_payload, dict) and session_payload_field - else None - ) - if isinstance(raw_session_id, str) and raw_session_id: - session_id = raw_session_id - except Exception: - pass + 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, @@ -412,6 +397,7 @@ def _run_discover(args) -> int: 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 @@ -482,6 +468,11 @@ def _detect_existing_install(client: str, config_path: Path) -> dict | None: return _detect_codex_install(config_path) +def _discover_script_path(config_path: Path) -> Path: + name = "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" + return config_path.parent / "hooks" / name + + def _install_hooks( client: str, hook_client: str, @@ -494,15 +485,14 @@ def _install_hooks( tenant_id: str, snyk_token: str, machine_id: str, -) -> Path: +) -> 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 is_codex_requirements = _is_codex_requirements_toml(config_path) - discover_script_name = "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" - discover_script_path = config_path.parent / "hooks" / discover_script_name + discover_script_path = _discover_script_path(config_path) discover_script_existed = discover_script_path.exists() ( dest_path, @@ -523,11 +513,10 @@ def _install_hooks( ) discover_command = None if not is_codex_requirements: - installed_discover_script_path = dest_path.with_name(discover_script_name) discover_command = _build_discover_hook_command( push_key, url, - installed_discover_script_path, + discover_script_path, tenant_id=tenant_id, machine_id=machine_id, hook_client=hook_client, @@ -575,7 +564,6 @@ def _install_hooks( rich.print(f" Remote URL: [dim]{url}[/dim]") rich.print(f" Push Key: [yellow]{_mask_key(push_key)}[/yellow]") rich.print() - return dest_path def _prepare_claude_config( @@ -1197,7 +1185,11 @@ def _servers_discovered_entries(clients_to_inspect: list[ClientToInspect]) -> li return [request.model_dump(mode="json") for request in build_scan_request(inspected_paths).scan_path_requests] -def _discover_servers_payload(target_folders: list[str] | None = None) -> list[dict]: +def _discover_servers_payload( + target_folders: list[str] | None = None, + *, + discovery_scope: DiscoveryScope = DiscoveryScope.ALL, +) -> list[dict]: import asyncio from agent_scan import pipelines @@ -1207,11 +1199,14 @@ def _discover_servers_payload(target_folders: list[str] | None = None) -> list[d timeout=0, tokens=[], paths=[], + discovery_scope=discovery_scope, target_folders=target_folders or [], ) + cancel = threading.Event() clients_to_inspect, _, _ = _run_with_timeout( - lambda: asyncio.run(pipelines.discover_clients_to_inspect(inspect_args)), + lambda: asyncio.run(pipelines.discover_clients_to_inspect(inspect_args, cancel=cancel)), _discovery_timeout_seconds(), + cancel=cancel, ) return _servers_discovered_entries(clients_to_inspect) @@ -1222,10 +1217,14 @@ def _invoke_hook_script( push_key: str, url: str, payload: str, - machine_id: str = "", + *, + machine_id: str, ) -> tuple[bool, str]: import subprocess + if not machine_id.strip(): + raise ValueError("machine ID is required") + if IS_WINDOWS: cmd = [ "powershell", @@ -1237,9 +1236,9 @@ def _invoke_hook_script( push_key, "-RemoteUrl", url, + "-MachineId", + machine_id, ] - if machine_id: - cmd.extend(["-MachineId", machine_id]) env = None else: cmd = ["bash", str(script_path), "--client", hook_client] @@ -1247,9 +1246,8 @@ def _invoke_hook_script( **os.environ, "PUSH_KEY": push_key, "REMOTE_HOOKS_BASE_URL": url, + "MACHINE_ID": machine_id, } - if machine_id: - env["MACHINE_ID"] = machine_id try: result = subprocess.run( @@ -1283,14 +1281,13 @@ def _send_test_event( new_checksum: str | None = None, discover_current_checksum: str | None = None, discover_new_checksum: str | None = None, - machine_id: str = "", + machine_id: str, ) -> bool: """Send a test hooksConfigured event by invoking the hook script. Returns True on success.""" + 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: @@ -1313,7 +1310,14 @@ def _send_test_event( redact_push_keys_in_data(payload_dict) payload = json.dumps(payload_dict) - ok, detail = _invoke_hook_script(script_path, hook_client, push_key, url, payload, machine_id) + 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 @@ -1330,11 +1334,12 @@ def _send_servers_discovered_event( event_name: str = "serversDiscovered", session_marker: str = "hooks-setup", target_folders: list[str] | None = None, + discovery_scope: DiscoveryScope = DiscoveryScope.ALL, ) -> bool: rich.print("[dim]Discovering MCP servers...[/dim]") started = time.monotonic() try: - servers = _discover_servers_payload(target_folders) + servers = _discover_servers_payload(target_folders, discovery_scope=discovery_scope) except Exception as e: rich.print(f"[yellow]Warning:[/yellow] Could not discover MCP servers: {e}") return False @@ -1345,10 +1350,7 @@ def _send_servers_discovered_event( "servers": servers, "discovery_duration_ms": duration_ms, } - if hook_client == "claude-code" or hook_client == "codex": - payload_dict["session_id"] = session_marker - else: - payload_dict["conversation_id"] = session_marker + payload_dict[HOOK_CLIENTS[hook_client].session_field] = session_marker redact_push_keys_in_data(payload_dict) payload = json.dumps(payload_dict) @@ -1642,8 +1644,6 @@ def _build_discover_hook_command( f"PUSH_KEY={_shell_quote(push_key)}", f"REMOTE_HOOKS_BASE_URL={_shell_quote(url)}", ] - if tenant_id: - parts.append(f"TENANT_ID={_shell_quote(tenant_id)}") if machine_id: parts.append(f"MACHINE_ID={_shell_quote(machine_id)}") agent_scan_bin = _agent_scan_bin() @@ -1651,6 +1651,7 @@ def _build_discover_hook_command( parts.append(f"AGENT_SCAN_BIN={_shell_quote(agent_scan_bin)}") parts.append(f"bash {_shell_quote(script_path.as_posix())}") parts.append(f"--client {_shell_quote(hook_client)}") + parts.append("--scope servers") return " ".join(parts) @@ -1672,6 +1673,7 @@ def _build_discover_hook_command_powershell( agent_scan_bin = _agent_scan_bin() if agent_scan_bin is not None: command += f" -AgentScanBin {_ps_quote(agent_scan_bin)}" + command += " -Scope servers" return command @@ -1755,9 +1757,8 @@ def _copy_hook_script(config_path: Path, *, include_discover: bool = True) -> _H discover_new_checksum: str | None = None discover_updated = False if include_discover: - discover_name = "snyk-agent-guard-discover.ps1" if IS_WINDOWS else "snyk-agent-guard-discover.sh" - discover_source = hook_pkg.joinpath(discover_name) - discover_dest = dest_dir / discover_name + discover_dest = _discover_script_path(config_path) + discover_source = hook_pkg.joinpath(discover_dest.name) discover_content = discover_source.read_bytes() discover_new_checksum = hashlib.sha256(discover_content).hexdigest() discover_existing_content: bytes | None = None diff --git a/src/agent_scan/hook_events.py b/src/agent_scan/hook_events.py index e6b5c79d..e464b161 100644 --- a/src/agent_scan/hook_events.py +++ b/src/agent_scan/hook_events.py @@ -5,6 +5,7 @@ import base64 import json import sys +from typing import NamedTuple from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen @@ -12,10 +13,17 @@ from agent_scan.utils import get_hostname, get_username from agent_scan.version import version_info -_HOOK_ENDPOINTS = { - "claude-code": "/hidden/agent-monitor/hooks/claude-code", - "cursor": "/hidden/agent-monitor/hooks/cursor", - "codex": "/hidden/agent-monitor/hooks/codex", + +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 @@ -28,8 +36,8 @@ def send_hook_event( machine_id: str, ) -> tuple[bool, str]: """POST a hook event using the same wire contract as the hook scripts.""" - endpoint = _HOOK_ENDPOINTS.get(hook_client) - if endpoint is None: + 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" @@ -46,7 +54,7 @@ def send_hook_event( encoded_payload = base64.b64encode(payload.encode()).decode() body = f"base64:{encoded_payload}".encode() script_extension = "ps1" if sys.platform == "win32" else "sh" - url = f"{base_url.rstrip('/')}{endpoint}?version={HOOK_VERSION}" + url = f"{base_url.rstrip('/')}{client.endpoint}?version={HOOK_VERSION}" request = Request(url, data=body, method="POST") request.add_header("User-Agent", f"snyk/snyk-agent-guard.{script_extension} Agent Scan v{version_info}") @@ -55,10 +63,8 @@ def send_hook_event( request.add_header("X-Client-Id", push_key) try: - with urlopen(request, timeout=_HOOK_REQUEST_TIMEOUT_SECONDS) as response: - status = getattr(response, "status", 200) - if status >= 400: - return False, f"HTTP {status}" + with urlopen(request, timeout=_HOOK_REQUEST_TIMEOUT_SECONDS): + pass return True, "" except HTTPError as error: return False, f"HTTP {error.code}" diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 index d9c978f9..3783a615 100644 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -18,7 +18,11 @@ param( [string]$MachineId, [Parameter(Mandatory=$false)] - [string]$AgentScanBin + [string]$AgentScanBin, + + [Parameter(Mandatory=$false)] + [ValidateSet("servers","skills","all")] + [string]$Scope = "servers" ) $ErrorActionPreference = "Stop" @@ -26,15 +30,19 @@ $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 1 } +if (-not $MachineId) { exit 0 } $env:MACHINE_ID = $MachineId $bin = if ($AgentScanBin) { $AgentScanBin } elseif ($env:AGENT_SCAN_BIN) { $env:AGENT_SCAN_BIN } else { "snyk-agent-scan" } if (-not (Get-Command $bin -ErrorAction SilentlyContinue)) { $bin = "snyk-agent-scan" } -$arguments = @("guard", "discover", "--client", $Client) +$arguments = @("guard", "discover", "--client", $Client, "--scope", $Scope) $reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8, $true) $payload = $reader.ReadToEnd() -$payload | & $bin @arguments *> $null -exit $LASTEXITCODE +try { + $payload | & $bin @arguments *> $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 index 009fd763..ab0ffb34 100755 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.sh +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -[[ -n "${MACHINE_ID:-}" ]] || exit 1 +[[ -n "${MACHINE_ID:-}" ]] || exit 0 bin="${AGENT_SCAN_BIN:-snyk-agent-scan}" if ! command -v "$bin" >/dev/null 2>&1; then bin="snyk-agent-scan" fi -exec "$bin" guard discover "$@" >/dev/null 2>&1 +"$bin" guard discover "$@" >/dev/null 2>&1 || true +exit 0 diff --git a/src/agent_scan/pipelines.py b/src/agent_scan/pipelines.py index 35c6b6db..b344e3ab 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -1,11 +1,12 @@ import getpass import logging import os +import threading from pathlib import Path 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, @@ -22,7 +23,7 @@ TokenAndClientInfo, ) from agent_scan.redact import redact_inspected_path -from agent_scan.utils import get_readable_home_directories +from agent_scan.utils import get_readable_home_directories, safe_resolve from agent_scan.verify_api import analyze_machine from agent_scan.well_known_clients import get_well_known_clients @@ -35,6 +36,7 @@ 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) @@ -59,6 +61,8 @@ class PushArgs(BaseModel): async def discover_clients_to_inspect( inspect_args: InspectArgs, + *, + cancel: threading.Event | None = None, ) -> tuple[list[ClientToInspect], list[InspectedPath], list[str]]: """ Discover the clients/configs that would be inspected, without actually @@ -90,10 +94,7 @@ async def discover_clients_to_inspect( 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: - key = target_path + key = safe_resolve(target_path) if key in seen_target_folders: continue seen_target_folders.add(key) @@ -104,6 +105,8 @@ async def discover_clients_to_inspect( # Phase A — legacy path. Runs for EVERY well-known client including Claude Code. for client in get_well_known_clients(): + if cancel is not None and cancel.is_set(): + break ctis = await get_mcp_config_per_client(client, home_dirs_with_users) if ctis: clients_to_inspect.extend(ctis) @@ -112,9 +115,13 @@ 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: + if cancel is not None and cancel.is_set(): + break for discoverer in find_discoverers(home_directory, target_folders=target_folders): + if cancel is not None and cancel.is_set(): + break 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..948bf4fe 100644 --- a/src/agent_scan/utils.py +++ b/src/agent_scan/utils.py @@ -191,6 +191,14 @@ def get_push_key(control_servers: list[ControlServer] | list[dict[str, Any]]) -> return None +def safe_resolve(path: Path) -> Path: + """Resolve ``path`` when possible, preserving its literal spelling on failure.""" + try: + return path.resolve() + except (OSError, RuntimeError): + return path + + def get_readable_home_directories(all_users: bool = False) -> list[tuple[Path, str]]: """ Retrieve a list of all human user home directories on the machine diff --git a/tests/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index 1c27b8fb..6ec3a48d 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -1,7 +1,10 @@ """Tests for the per-agent discovery ABC (agent_scan.agents package).""" +import json import sys -from unittest.mock import patch +import threading +from pathlib import Path +from unittest.mock import MagicMock, patch import pytest @@ -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 --- @@ -8883,6 +8912,60 @@ def test_target_folders_gain_ancestors_and_dedup_recorded_roots(tmp_path): 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 @@ -9103,3 +9186,43 @@ async def test_pipeline_explicit_paths_ignore_target_folders(tmp_path): 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_preset_cancel_skips_discovery(tmp_path): + from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect + + cancel = threading.Event() + cancel.set() + discoverer = MagicMock() + + with ( + patch("agent_scan.pipelines.get_readable_home_directories", return_value=[(tmp_path, "alice")]), + patch("agent_scan.pipelines.get_well_known_clients", return_value=[]), + patch("agent_scan.pipelines.find_discoverers", return_value=[discoverer]), + ): + await discover_clients_to_inspect(InspectArgs(timeout=0, tokens=[], paths=[]), cancel=cancel) + + discoverer.discover.assert_not_called() diff --git a/tests/unit/test_cli_config_file.py b/tests/unit/test_cli_config_file.py index 928f2652..545de313 100644 --- a/tests/unit/test_cli_config_file.py +++ b/tests/unit/test_cli_config_file.py @@ -92,6 +92,12 @@ def test_boolean_optional_both_spellings_map_to_same_dest(self): assert "skills" in explicitly_provided_dests(parser, ["scan", "--no-skills"]) assert "skills" in explicitly_provided_dests(parser, ["scan", "--skills"]) + def test_double_dash_stops_option_detection(self): + parser = _build_parser() + + assert "json" not in explicitly_provided_dests(parser, ["scan", "--", "--json"]) + assert "json" in explicitly_provided_dests(parser, ["scan", "--json"]) + def test_uses_destination_from_active_subparser_when_option_aliases_collide(self): parser = _build_parser() subparsers = next(action for action in parser._actions if isinstance(action, argparse._SubParsersAction)) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index f4121a03..6cf255ad 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -387,7 +387,9 @@ class TestBuildDiscoverHookCommand: [("claude-code", "cwd"), ("cursor", "workspace_roots"), ("codex", "cwd")], ) def test_client_payload_fields_match_hook_schemas(self, client, expected_field): - assert guard_module._HOOK_CLIENT_TARGET_FOLDER_FIELDS[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_binary(self, client): @@ -406,10 +408,10 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): assert "PUSH_KEY='pk'" in command assert "REMOTE_HOOKS_BASE_URL='https://api.snyk.io'" in command - assert "TENANT_ID='tenant'" in command + assert "TENANT_ID=" not in command assert "MACHINE_ID='machine'" in command assert "AGENT_SCAN_BIN='/opt/Snyk'\"'\"'s bin/snyk-agent-scan'" in command - assert command.endswith(f"bash '/x/snyk-agent-guard-discover.sh' --client '{client}'") + assert command.endswith(f"bash '/x/snyk-agent-guard-discover.sh' --client '{client}' --scope servers") assert _is_agent_scan_command(command) def test_omits_agent_scan_binary_when_unresolved(self): @@ -425,7 +427,7 @@ def test_omits_agent_scan_binary_when_unresolved(self): ) assert "AGENT_SCAN_BIN" not in command - assert command.endswith("--client 'cursor'") + assert command.endswith("--client 'cursor' --scope servers") @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) def test_builds_powershell_command_for_each_client(self, client): @@ -445,7 +447,7 @@ def test_builds_powershell_command_for_each_client(self, client): 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"-AgentScanBin 'C:\Program Files\Snyk\snyk-agent-scan.exe'" + r"-AgentScanBin 'C:\Program Files\Snyk\snyk-agent-scan.exe' -Scope servers" ) def test_powershell_escapes_single_quotes_in_paths(self): @@ -654,12 +656,13 @@ def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_pat assert discover_script.read_text() == ( "#!/usr/bin/env bash\nset -euo pipefail\n" - '[[ -n "${MACHINE_ID:-}" ]] || exit 1\n' + '[[ -n "${MACHINE_ID:-}" ]] || exit 0\n' 'bin="${AGENT_SCAN_BIN:-snyk-agent-scan}"\n' 'if ! command -v "$bin" >/dev/null 2>&1; then\n' ' bin="snyk-agent-scan"\n' "fi\n" - 'exec "$bin" guard discover "$@" >/dev/null 2>&1\n' + '"$bin" guard discover "$@" >/dev/null 2>&1 || true\n' + "exit 0\n" ) assert os.access(discover_script, os.X_OK) @@ -707,6 +710,44 @@ def test_stale_absolute_binary_falls_back_to_path(self, tmp_path): assert result.returncode == 0 assert marker.read_text().strip() == "guard discover --client claude-code" + 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_BIN": str(stub), "MACHINE_ID": "machine-42"}, + ) + + assert result.returncode == 0 + + def test_missing_machine_id_exits_zero_without_invoking_binary(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_BIN": 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" main_script, *_ = guard_module._copy_hook_script(config) @@ -2427,7 +2468,7 @@ def test_windows_builds_discovery_hook(self, ctx, tmp_path): self._call(tmp_path, client="claude") ctx["build_discover"].assert_called_once() - ctx["dest"].with_name.assert_called_once_with("snyk-agent-guard-discover.ps1") + 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): @@ -2445,7 +2486,7 @@ def test_codex_managed_does_not_build_discovery_hook(self, ctx, tmp_path): ctx["build_discover"].assert_not_called() ctx["copy"].assert_called_once_with(config, include_discover=False) - def test_returns_installed_script_path(self, ctx, tmp_path): + def test_install_hooks_returns_none(self, ctx, tmp_path): result = _install_hooks( "claude", "claude-code", @@ -2459,7 +2500,7 @@ def test_returns_installed_script_path(self, ctx, tmp_path): "snyk-tok", "", ) - assert result == ctx["dest"] + assert result is None # --------------------------------------------------------------- # Client routing: each client calls its own prepare + write @@ -2859,6 +2900,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"] @@ -3065,6 +3107,16 @@ def test_uses_current_user_server_only_discovery(self): assert args.scan_skills is False 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=([], [], [])) @@ -3103,7 +3155,12 @@ def test_posix_invocation_sets_machine_id(self, monkeypatch): 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-42" + PurePosixPath("/hook.sh"), + "claude-code", + "pk", + "https://api.snyk.io", + "{}", + machine_id="machine-42", ) assert result == (True, "") @@ -3111,23 +3168,38 @@ def test_posix_invocation_sets_machine_id(self, monkeypatch): assert run.call_args.kwargs["env"]["MACHINE_ID"] == "machine-42" assert run.call_args.kwargs["input"] == "{}" - def test_posix_invocation_omits_machine_id_when_unset(self, monkeypatch): - monkeypatch.delenv("MACHINE_ID", raising=False) + 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", "{}" + PurePosixPath("/hook.sh"), + "cursor", + "pk", + "https://api.snyk.io", + "{}", + machine_id="chosen-machine", ) assert result == (True, "") - assert "MACHINE_ID" not in run.call_args.kwargs["env"] + assert run.call_args.kwargs["env"]["MACHINE_ID"] == "chosen-machine" - @pytest.mark.parametrize("machine_id, expected_tail", [("", []), ("machine-42", ["-MachineId", "machine-42"])]) - def test_windows_invocation_machine_id_shape(self, machine_id, expected_tail): + 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 + Path("C:/hook.ps1"), + "codex", + "pk", + "https://api.snyk.io", + "{}", + machine_id="machine-42", ) assert result == (True, "") @@ -3141,17 +3213,35 @@ def test_windows_invocation_machine_id_shape(self, machine_id, expected_tail): "pk", "-RemoteUrl", "https://api.snyk.io", - *expected_tail, + "-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", "{}") + 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_signals_and_joins_cooperative_worker(): + cancel = threading.Event() + stopped = threading.Event() + + def worker(): + cancel.wait() + stopped.set() + + with pytest.raises(TimeoutError, match="timed out"): + guard_module._run_with_timeout(worker, 0.01, cancel=cancel) + + assert cancel.is_set() + assert stopped.wait(0.2) + + class TestSendServersDiscoveredEvent: @staticmethod def _capture(hook_client="claude-code", entries=None, machine_id="machine-42"): @@ -3274,7 +3364,7 @@ def test_discovery_timeout_warns_without_sending(self): import asyncio import time as test_time - async def slow_discovery(_inspect_args): + async def slow_discovery(_inspect_args, *, cancel=None): await asyncio.sleep(0.5) return [], [], [] @@ -3333,8 +3423,25 @@ def test_parses_url(self, monkeypatch): 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 @@ -3392,6 +3499,7 @@ def _args(config: Path, url=None, **overrides): "guard_command": "discover", "url": url, "client": "claude-code", + "scope": "all", } values.update(overrides) return SimpleNamespace(**values) @@ -3507,7 +3615,7 @@ def test_hook_stdin_reads_cwd_for_claude_code(self, tmp_path, monkeypatch): assert result == 0 stdin.read.assert_called_once_with(1024 * 1024) - discover.assert_called_once_with(["/session/project"]) + 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): @@ -3529,7 +3637,7 @@ def test_hook_stdin_reads_cwd_for_codex(self, tmp_path, monkeypatch): assert result == 0 stdin.read.assert_called_once_with(1024 * 1024) - discover.assert_called_once_with(["/session/project"]) + 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): @@ -3550,7 +3658,7 @@ def test_hook_stdin_accepts_workspace_roots_list(self, tmp_path, monkeypatch): result = guard_module.run_guard(self._args(config, client="cursor")) assert result == 0 - discover.assert_called_once_with(["/workspace/one", "/workspace/two"]) + 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" @@ -3575,7 +3683,7 @@ def test_malformed_hook_stdin_is_ignored(self, tmp_path, monkeypatch): ) assert result == 0 - discover.assert_called_once_with([]) + 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): @@ -3596,7 +3704,7 @@ def test_tty_stdin_is_not_read(self, tmp_path, monkeypatch): assert result == 0 stdin.read.assert_not_called() - discover.assert_called_once_with([]) + 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 diff --git a/tests/unit/test_hook_events.py b/tests/unit/test_hook_events.py index 71ad36ce..e6f3bea7 100644 --- a/tests/unit/test_hook_events.py +++ b/tests/unit/test_hook_events.py @@ -4,6 +4,7 @@ import base64 import json +from email.message import Message from types import SimpleNamespace from unittest.mock import MagicMock, patch from urllib.error import HTTPError, URLError @@ -53,7 +54,7 @@ def test_rejects_missing_machine_identifier_without_request(): @pytest.mark.parametrize( "error, expected", [ - (HTTPError("https://api.snyk.io", 403, "Forbidden", None, None), "HTTP 403"), + (HTTPError("https://api.snyk.io", 403, "Forbidden", Message(), None), "HTTP 403"), (URLError("offline"), "offline"), (TimeoutError("timed out"), "timed out"), ], @@ -66,6 +67,15 @@ def test_reports_http_and_network_failures(error, expected): assert expected in detail +def test_http_404_is_reported_from_urlopen_exception_path(): + error = HTTPError("https://api.snyk.io", 404, "Not Found", Message(), None) + + with patch("agent_scan.hook_events.urlopen", side_effect=error): + result = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", "machine-1") + + assert result == (False, "HTTP 404") + + def test_rejects_unknown_client_without_request(): with patch("agent_scan.hook_events.urlopen") as urlopen: result = send_hook_event("https://api.snyk.io", "unknown", "push-key", "{}", "machine-1") From 4933baa951318111dca7f7083fcf09fdffbf90f5 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Tue, 25 Aug 2026 14:49:55 +0200 Subject: [PATCH 27/58] fix: address PR 443 review findings - send hook events through the shared platform session (certifi plus the CA certs load_extra_ca_certs picks up from the environment), so Agent Guard delivery has the same trust posture as the analysis path; retries stay opt-in so session-start discovery keeps a single attempt - request only servers for the install-time discovery event, and honor discovery_scope in the well-known-client path so a servers-only request no longer walks every skills dir - let the Windows discovery trampoline hand its stdin to the child like the POSIX one does, instead of a blocking ReadToEnd outside the try/catch, and cover the script with executing tests for the first time - catch ValueError in safe_resolve: Path.resolve raises it for a NUL byte, and target folders arrive from untrusted hook payloads - resolve the active subparser from the parsed namespace instead of re-walking argv, dropping the hand-rolled option-width heuristic - drop the two path helpers left with no callers, and repoint the tests and docstrings at _discovery_paths_with_ancestors --- src/agent_scan/agents/base.py | 35 ++-- src/agent_scan/agents/claude_code.py | 2 +- src/agent_scan/agents/opencode.py | 2 +- src/agent_scan/agents/vscode/antigravity.py | 2 +- src/agent_scan/agents/vscode/base.py | 2 +- src/agent_scan/cli.py | 61 +++---- src/agent_scan/guard.py | 14 +- src/agent_scan/hook_events.py | 57 +++++-- .../hooks/snyk-agent-guard-discover.ps1 | 12 +- src/agent_scan/inspect.py | 58 ++++--- src/agent_scan/pipelines.py | 2 +- src/agent_scan/utils.py | 9 +- src/agent_scan/verify_api.py | 21 ++- tests/unit/test_agent_discovery.py | 73 ++++++--- tests/unit/test_cli_config_file.py | 54 +++++-- tests/unit/test_guard.py | 153 +++++++++++++++++- tests/unit/test_hook_events.py | 144 +++++++++++++---- tests/unit/test_inspect.py | 82 ++++++++++ tests/unit/test_utils.py | 37 +++++ 19 files changed, 624 insertions(+), 196 deletions(-) diff --git a/src/agent_scan/agents/base.py b/src/agent_scan/agents/base.py index 2c86540e..a6df1bf1 100644 --- a/src/agent_scan/agents/base.py +++ b/src/agent_scan/agents/base.py @@ -161,12 +161,11 @@ def __init__(self, home_directory: Path | None, target_folders: list[Path] | Non # (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() self.target_folders = list(target_folders or []) - # Lazily-populated caches for recorded project roots and explicit target - # roots. A discoverer serves a single scan (see find_discoverers), so both - # lists are stable for its lifetime and discovery does not need to re-walk - # workspaceStorage / re-read ~/.claude.json each time. - self._project_paths_cache: list[Path] | None = None - self._target_paths_cache: list[Path] | None = None + # 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: @@ -518,29 +517,15 @@ def _folders_with_ancestors(folders: list[Path]) -> list[Path]: cur = parent return result - def _project_paths_with_ancestors(self) -> list[Path]: - """Project roots plus every ancestor up to filesystem root, deduplicated. + 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). + 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._project_paths_cache is not None: - return self._project_paths_cache - self._project_paths_cache = self._folders_with_ancestors(self._all_project_folders()) - return self._project_paths_cache - - def _target_paths_with_ancestors(self) -> list[Path]: - """Target roots plus every ancestor up to filesystem root, deduplicated.""" - if self._target_paths_cache is not None: - return self._target_paths_cache - self._target_paths_cache = self._folders_with_ancestors(self._all_target_folders()) - return self._target_paths_cache - - def _discovery_paths_with_ancestors(self) -> list[Path]: - """Return project and target paths with ancestors, deduplicated across both.""" 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()) diff --git a/src/agent_scan/agents/claude_code.py b/src/agent_scan/agents/claude_code.py index 72034d87..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: diff --git a/src/agent_scan/agents/opencode.py b/src/agent_scan/agents/opencode.py index 4d227d30..9d7ecd54 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}`` plus ``/.opencode/{skills,skill}``. * Managed — per-OS system-wide ``opencode.{json,jsonc}`` (and skill dirs) 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 f298133c..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://`` diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index 3d8b8f3b..c04cc6f8 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -228,57 +228,38 @@ def _iter_all_actions(parser: argparse.ArgumentParser): yield action -def _option_value_count(action: argparse.Action) -> int: - """Return how many argv tokens follow an option for the shapes this CLI uses.""" - if action.nargs == 0: - return 0 - if isinstance(action.nargs, int): - return action.nargs - return 1 - +def _iter_active_actions(parser: argparse.ArgumentParser, args: argparse.Namespace): + """Yield actions from the subparser path argparse already resolved into ``args``. -def _iter_active_actions(parser: argparse.ArgumentParser, argv: list[str]): - """Yield actions from the parser path selected while consuming ``argv`` like argparse.""" - subparsers: argparse._SubParsersAction | None = None - values_consumed: dict[str, int] = {} + Each ``add_subparsers`` call names a dest (``command``, ``guard_command``), so the + selected subcommand can be read straight off the namespace instead of re-walking + argv. An unset dest means that level was not reached, and the walk stops there. + """ for action in parser._actions: if isinstance(action, argparse._SubParsersAction): - subparsers = action - continue - yield action - for option in action.option_strings: - values_consumed[option] = _option_value_count(action) - - if subparsers is None: - return - - index = 0 - while index < len(argv): - token = argv[index] - if token == "--": - return - if token.startswith("-") and token != "-": - index += 1 if "=" in token else 1 + values_consumed.get(token, 0) - continue - subparser = subparsers.choices.get(token) - if subparser is not None: - yield from _iter_active_actions(subparser, argv[index + 1 :]) - return + selected = getattr(args, action.dest, None) + chosen = action.choices.get(selected) if isinstance(selected, str) else None + if chosen is not None: + yield from _iter_active_actions(chosen, args) + else: + yield action -def explicitly_provided_dests(parser: argparse.ArgumentParser, argv: list[str]) -> set[str]: +def explicitly_provided_dests(parser: argparse.ArgumentParser, args: argparse.Namespace, argv: list[str]) -> set[str]: """ Return the set of argument ``dest`` names the user passed explicitly on the command line. - We inspect the raw ``argv`` rather than the parsed namespace because argparse + We scan the raw ``argv`` rather than reading the parsed namespace because argparse cannot distinguish "flag omitted" (dest holds its default) from "flag passed - with a value equal to its default". Both ``--flag value`` and ``--flag=value`` - spellings are recognized, as are the two option strings of a - BooleanOptionalAction (``--skills`` / ``--no-skills`` both map to ``skills``). + with a value equal to its default". ``args`` is used only to know which subparser + is active, which is what disambiguates an option string defined on more than one + subcommand. Both ``--flag value`` and ``--flag=value`` spellings are recognized, + as are the two option strings of a BooleanOptionalAction (``--skills`` / + ``--no-skills`` both map to ``skills``). """ option_to_dest: dict[str, str] = {} - for action in _iter_active_actions(parser, argv): + for action in _iter_active_actions(parser, args): for option in action.option_strings: option_to_dest[option] = action.dest @@ -455,7 +436,7 @@ def apply_config_file(parser: argparse.ArgumentParser, args: argparse.Namespace, return config = load_config_file(config_path) - explicit = explicitly_provided_dests(parser, argv) + explicit = explicitly_provided_dests(parser, args, argv) # The positional ``files`` list has no option string, so treat any positional # value present on the CLI as an explicit override of the YAML ``files``. diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index f7e2cbb2..fab1aa2b 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -302,7 +302,16 @@ def _run_install(args) -> None: raise if first_installed_client is not None: - _send_servers_discovered_event(push_key, url, first_installed_client, machine_id) + # ``_servers_discovered_entries`` never emits skills, so requesting them here + # would walk every skills dir only to discard the result. + _send_servers_discovered_event( + push_key, + url, + first_installed_client, + machine_id, + discovery_scope=DiscoveryScope.SERVERS, + max_retries=2, + ) def _run_with_timeout( @@ -1335,6 +1344,7 @@ def _send_servers_discovered_event( session_marker: str = "hooks-setup", target_folders: list[str] | None = None, discovery_scope: DiscoveryScope = DiscoveryScope.ALL, + max_retries: int = 1, ) -> bool: rich.print("[dim]Discovering MCP servers...[/dim]") started = time.monotonic() @@ -1354,7 +1364,7 @@ def _send_servers_discovered_event( 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) + 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" diff --git a/src/agent_scan/hook_events.py b/src/agent_scan/hook_events.py index e464b161..068986f4 100644 --- a/src/agent_scan/hook_events.py +++ b/src/agent_scan/hook_events.py @@ -2,15 +2,17 @@ from __future__ import annotations +import asyncio import base64 import json import sys from typing import NamedTuple -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen + +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, platform_client_session from agent_scan.version import version_info @@ -28,14 +30,42 @@ class HookClient(NamedTuple): _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 platform_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.""" + """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}" @@ -55,20 +85,15 @@ def send_hook_event( body = f"base64:{encoded_payload}".encode() script_extension = "ps1" if sys.platform == "win32" else "sh" url = f"{base_url.rstrip('/')}{client.endpoint}?version={HOOK_VERSION}" - - request = Request(url, data=body, method="POST") - request.add_header("User-Agent", f"snyk/snyk-agent-guard.{script_extension} Agent Scan v{version_info}") - request.add_header("X-User", x_user) - request.add_header("Content-Type", "text/plain") - request.add_header("X-Client-Id", push_key) + headers = { + "User-Agent": f"snyk/snyk-agent-guard.{script_extension} Agent Scan v{version_info}", + "X-User": x_user, + "Content-Type": "text/plain", + "X-Client-Id": push_key, + } try: - with urlopen(request, timeout=_HOOK_REQUEST_TIMEOUT_SECONDS): - pass - return True, "" - except HTTPError as error: - return False, f"HTTP {error.code}" - except (TimeoutError, URLError) as error: - return False, str(error) + 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 index 3783a615..a3f133f8 100644 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -1,7 +1,7 @@ # # Session-start discovery trampoline for Snyk Agent Guard (Windows). -# Sets the environment expected by `guard discover` and forwards the hook -# payload from stdin. Parameters mirror snyk-agent-guard.ps1. +# 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)] @@ -38,10 +38,12 @@ if (-not (Get-Command $bin -ErrorAction SilentlyContinue)) { $bin = "snyk-agent- $arguments = @("guard", "discover", "--client", $Client, "--scope", $Scope) -$reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8, $true) -$payload = $reader.ReadToEnd() +# 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 { - $payload | & $bin @arguments *> $null + & $bin @arguments *> $null } catch { # Session-start discovery is best-effort telemetry. } 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 b344e3ab..e6df9769 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -107,7 +107,7 @@ async def discover_clients_to_inspect( for client in get_well_known_clients(): if cancel is not None and cancel.is_set(): break - 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: diff --git a/src/agent_scan/utils.py b/src/agent_scan/utils.py index 948bf4fe..fd4f8e2c 100644 --- a/src/agent_scan/utils.py +++ b/src/agent_scan/utils.py @@ -192,10 +192,15 @@ def get_push_key(control_servers: list[ControlServer] | list[dict[str, Any]]) -> def safe_resolve(path: Path) -> Path: - """Resolve ``path`` when possible, preserving its literal spelling on failure.""" + """Resolve ``path`` when possible, preserving its literal spelling on failure. + + ``ValueError`` is caught alongside the OS errors because ``Path.resolve()`` + raises it for a path containing a NUL byte, and target folders reach this + helper straight from untrusted hook-payload JSON. + """ try: return path.resolve() - except (OSError, RuntimeError): + except (OSError, RuntimeError, ValueError): return path diff --git a/src/agent_scan/verify_api.py b/src/agent_scan/verify_api.py index ae36dfb1..389ab8d1 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 platform_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 platform_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 platform_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 platform (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), @@ -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 platform_client_session(trace_configs, skip_ssl_verify) as session: async with session.post( analysis_url, data=payload.model_dump_json(), diff --git a/tests/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index 6ec3a48d..75226891 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -4,10 +4,11 @@ import sys import threading from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from agent_scan.agents import DiscoveryScope from agent_scan.models import ( ClientToInspect, CouldNotParseMCPConfig, @@ -241,22 +242,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 @@ -265,7 +266,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 @@ -274,7 +275,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 @@ -285,7 +286,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) @@ -300,7 +301,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 @@ -309,7 +310,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("/")] @@ -1298,6 +1299,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 @@ -3102,18 +3138,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 @@ -3124,12 +3160,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 --- @@ -8901,13 +8937,10 @@ def test_target_folders_gain_ancestors_and_dedup_recorded_roots(tmp_path): (tmp_path / ".claude.json").write_text(f'{{"projects": {{"{project.as_posix()}": {{}}}}}}') discoverer = ClaudeCodeDiscoverer(tmp_path, [project]) - project_paths = discoverer._project_paths_with_ancestors() - target_paths = discoverer._target_paths_with_ancestors() 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 in project_paths - assert project in target_paths assert project.parent in paths assert tmp_path in paths diff --git a/tests/unit/test_cli_config_file.py b/tests/unit/test_cli_config_file.py index 545de313..c34c638b 100644 --- a/tests/unit/test_cli_config_file.py +++ b/tests/unit/test_cli_config_file.py @@ -41,6 +41,11 @@ def _parse(argv: list[str]) -> tuple[argparse.ArgumentParser, argparse.Namespace return parser, args +def _provided(parser: argparse.ArgumentParser, argv: list[str]) -> set[str]: + """Let argparse resolve the subparser path, then ask which dests were explicit.""" + return explicitly_provided_dests(parser, parser.parse_args(argv), argv) + + def _write_yaml(tmp_path, text: str) -> str: path = tmp_path / "config.yaml" path.write_text(text) @@ -76,27 +81,21 @@ 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_double_dash_stops_option_detection(self): - parser = _build_parser() - - assert "json" not in explicitly_provided_dests(parser, ["scan", "--", "--json"]) - assert "json" in explicitly_provided_dests(parser, ["scan", "--json"]) + assert "json" not in _provided(_build_parser(), ["scan", "--", "--json"]) + assert "json" in _provided(_build_parser(), ["scan", "--json"]) def test_uses_destination_from_active_subparser_when_option_aliases_collide(self): parser = _build_parser() @@ -106,7 +105,7 @@ def test_uses_destination_from_active_subparser_when_option_aliases_collide(self guard_install_parser = guard_subparsers.add_parser("install", allow_abbrev=False) guard_install_parser.add_argument("--machine-id", "--control-identifier", dest="machine_id", default=None) - provided = explicitly_provided_dests( + provided = _provided( parser, ["scan", "--control-server", "https://example.com", "--control-identifier", "legacy-id"], ) @@ -114,6 +113,21 @@ def test_uses_destination_from_active_subparser_when_option_aliases_collide(self assert "control_identifier" in provided assert "machine_id" not in provided + def test_uses_destination_from_nested_subparser(self): + """The same alias resolves to guard install's dest when that is the active path.""" + parser = _build_parser() + subparsers = next(action for action in parser._actions if isinstance(action, argparse._SubParsersAction)) + guard_parser = subparsers.add_parser("guard", allow_abbrev=False) + guard_subparsers = guard_parser.add_subparsers(dest="guard_command") + guard_install_parser = guard_subparsers.add_parser("install", allow_abbrev=False) + guard_install_parser.add_argument("client") + guard_install_parser.add_argument("--machine-id", "--control-identifier", dest="machine_id", default=None) + + provided = _provided(parser, ["guard", "install", "claude", "--control-identifier", "m1"]) + + assert "machine_id" in provided + assert "control_identifier" not in provided + def test_root_option_value_is_not_mistaken_for_subcommand(self): parser = argparse.ArgumentParser(allow_abbrev=False) parser.add_argument("--config-file") @@ -124,13 +138,19 @@ def test_root_option_value_is_not_mistaken_for_subcommand(self): guard_subparsers = guard_parser.add_subparsers(dest="guard_command") guard_subparsers.add_parser("install", allow_abbrev=False) - provided = explicitly_provided_dests( - parser, - ["--config-file", "guard", "scan", "--control-identifier", "x"], - ) + provided = _provided(parser, ["--config-file", "guard", "scan", "--control-identifier", "x"]) assert "control_identifier" in provided + def test_no_subcommand_yields_only_root_options(self): + """A namespace whose subparser dest is unset must not crash the action walk.""" + parser = _build_parser() + parser.add_argument("--top-level") + + provided = explicitly_provided_dests(parser, parser.parse_args([]), ["--top-level", "x"]) + + assert provided == {"top_level"} + class TestAbbreviationDisabled: """main() sets allow_abbrev=False so prefix abbreviations are rejected, which diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 6cf255ad..1b37a0fb 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -1937,6 +1937,114 @@ def test_missing_url_fails(self): 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, + ["-AgentScanBin", 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, + ["-AgentScanBin", str(stub), "-MachineId", "machine-42"], + dict(os.environ), + ) + + assert result.returncode == 0 + assert result.stderr == "" + + def test_missing_machine_id_exits_zero_without_invoking_binary(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, ["-AgentScanBin", str(stub)], env) + + assert result.returncode == 0 + assert not marker.exists() + + def test_stale_absolute_binary_falls_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, + ["-AgentScanBin", str(tmp_path / "deleted" / "snyk-agent-scan.exe"), "-MachineId", "machine-42"], + env, + ) + + assert result.returncode == 0, result.stderr + assert 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.""" @@ -3247,7 +3355,7 @@ class TestSendServersDiscoveredEvent: def _capture(hook_client="claude-code", entries=None, machine_id="machine-42"): captured = {} - def fake_send(url, client, push_key, payload, identifier): + def fake_send(url, client, push_key, payload, identifier, **kwargs): captured.update( url=url, client=client, @@ -3296,7 +3404,7 @@ def test_empty_discovery_is_still_sent(self): def test_event_name_and_session_marker_can_be_overridden(self): captured = {} - def fake_send(_url, _client, _push_key, payload, _machine_id): + def fake_send(_url, _client, _push_key, payload, _machine_id, **kwargs): captured["payload"] = json.loads(payload) return True, "" @@ -3321,7 +3429,7 @@ def fake_send(_url, _client, _push_key, payload, _machine_id): def test_payload_includes_discovery_duration_ms_from_monotonic_clock(self): captured = {} - def fake_send(_url, _client, _push_key, payload, _machine_id): + def fake_send(_url, _client, _push_key, payload, _machine_id, **kwargs): captured["payload"] = json.loads(payload) return True, "" @@ -3508,7 +3616,7 @@ def test_happy_path_sends_session_start_discovery_from_environment(self, tmp_pat config = tmp_path / "custom" / "settings.json" captured = {} - def fake_send(url, client, push_key, payload, machine_id): + def fake_send(url, client, push_key, payload, machine_id, **kwargs): captured.update( url=url, client=client, @@ -3839,6 +3947,8 @@ def _fake_paths(tmp_path, installed): 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 ( @@ -3848,7 +3958,38 @@ def test_single_client_sends_once_directly(self, tmp_path, monkeypatch): _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") + 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") @@ -3870,7 +4011,7 @@ def install(*args): order.append(f"install:{args[0]}") return scripts[len(order) - 1] - def send(*args): + def send(*args, **kwargs): order.append("send") return True diff --git a/tests/unit/test_hook_events.py b/tests/unit/test_hook_events.py index e6f3bea7..a7f42cbb 100644 --- a/tests/unit/test_hook_events.py +++ b/tests/unit/test_hook_events.py @@ -4,81 +4,163 @@ import base64 import json -from email.message import Message -from types import SimpleNamespace -from unittest.mock import MagicMock, patch -from urllib.error import HTTPError, URLError +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 +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 platform 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.platform_client_session", return_value=session) + + @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) def test_sends_existing_hook_wire_contract(client): - response = MagicMock() - response.__enter__.return_value = SimpleNamespace(status=200) + session = _FakeSession() payload = '{"hook_event_name":"serversDiscovered"}' with ( patch("agent_scan.hook_events.get_hostname", return_value="host-1"), patch("agent_scan.hook_events.get_username", return_value="user-1"), - patch("agent_scan.hook_events.urlopen", return_value=response) as urlopen, + _patch_session(session), ): result = send_hook_event("https://api.snyk.io/", client, "push-key", payload, "machine-1") assert result == (True, "") - request = urlopen.call_args.args[0] - assert request.full_url == f"https://api.snyk.io/hidden/agent-monitor/hooks/{client}?version={HOOK_VERSION}" - assert urlopen.call_args.kwargs["timeout"] == _HOOK_REQUEST_TIMEOUT_SECONDS - assert base64.b64decode(request.data.decode().removeprefix("base64:")).decode() == payload - assert request.get_header("Content-type") == "text/plain" - assert request.get_header("X-client-id") == "push-key" - assert "Agent Scan v" in request.get_header("User-agent") - assert json.loads(request.get_header("X-user")) == { + 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 "Agent Scan v" in headers["User-Agent"] + assert json.loads(headers["X-User"]) == { "hostname": "host-1", "username": "user-1", "identifier": "machine-1", } +def test_uses_the_shared_platform_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(): - with patch("agent_scan.hook_events.urlopen") as urlopen: + 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") - urlopen.assert_not_called() + 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", [ - (HTTPError("https://api.snyk.io", 403, "Forbidden", Message(), None), "HTTP 403"), - (URLError("offline"), "offline"), + (aiohttp.ClientConnectionError("offline"), "offline"), (TimeoutError("timed out"), "timed out"), ], ) -def test_reports_http_and_network_failures(error, expected): - with patch("agent_scan.hook_events.urlopen", side_effect=error): +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_http_404_is_reported_from_urlopen_exception_path(): - error = HTTPError("https://api.snyk.io", 404, "Not Found", Message(), None) +def test_transport_failures_are_retried_when_requested(): + session = _FakeSession(error=aiohttp.ClientConnectionError("offline")) - with patch("agent_scan.hook_events.urlopen", side_effect=error): - result = send_hook_event("https://api.snyk.io", "claude-code", "push-key", "{}", "machine-1") + 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 result == (False, "HTTP 404") + assert ok is False + assert len(session.posts) == 3 + assert sleep.await_count == 2 -def test_rejects_unknown_client_without_request(): - with patch("agent_scan.hook_events.urlopen") as urlopen: - result = send_hook_event("https://api.snyk.io", "unknown", "push-key", "{}", "machine-1") +def test_single_attempt_by_default(): + """SessionStart discovery runs inside a hook budget, so retries are opt-in.""" + session = _FakeSession(error=aiohttp.ClientConnectionError("offline")) - assert result == (False, "unknown client: unknown") - urlopen.assert_not_called() + 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..fbc0b4a3 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -2,7 +2,9 @@ import os import subprocess import sys +from pathlib import Path from types import SimpleNamespace +from unittest.mock import patch import pytest @@ -12,6 +14,7 @@ calculate_distance, get_readable_home_directories, get_relative_path, + safe_resolve, suppress_stdout, ) @@ -560,3 +563,37 @@ def fake_run(cmd, **_kwargs): usernames = {u for _p, u in result} assert usernames == {"wsl_alice"}, f"WSL homes must still surface when CIM query fails; got {usernames}" + + +class TestSafeResolve: + """``safe_resolve`` must never raise: callers rely on the literal path as a fallback.""" + + def test_resolves_a_real_path(self, tmp_path): + target = tmp_path / "project" + target.mkdir() + + assert safe_resolve(target) == target.resolve() + + def test_embedded_null_byte_returns_literal_path(self): + """``Path.resolve()`` raises ValueError (not OSError) for a NUL byte. + + Target folders reach ``safe_resolve`` from untrusted hook-payload JSON, so a + payload such as ``{"cwd": "a\\0b"}`` must not abort discovery. + """ + path = Path("a\x00b") + + assert safe_resolve(path) == path + + @pytest.mark.parametrize( + "error", + [ + OSError("stale NFS handle"), + RuntimeError("Symlink loop"), + ValueError("embedded null character in path"), + ], + ) + def test_resolution_failures_return_the_literal_path(self, error, tmp_path): + target = tmp_path / "project" + + with patch.object(Path, "resolve", side_effect=error): + assert safe_resolve(target) == target From 323ec3ee7ec8939fe69fbe9ee1f4d2e48e495cab Mon Sep 17 00:00:00 2001 From: iamcristi Date: Tue, 25 Aug 2026 15:50:04 +0200 Subject: [PATCH 28/58] refactor: inline resolve fallbacks, drop safe_resolve safe_resolve was extracted in 4cbf8f3 from two try/excepts that were already written inline, and its single "return the literal path on failure" contract does not fit all three of its callers. Inline the handling at each site instead and delete the helper. _scans_own_home goes back to failing closed, as it did before the extraction: routing it through safe_resolve let each side degrade to its literal path and keep comparing, so it could report own-home where it previously did not. That gate gets to decide whether this process's CLAUDE_CONFIG_DIR / VSCODE_PORTABLE apply to the home being scanned, so an unresolvable path must not count as proof. It still catches more than OSError, since everything now maps to the same safe answer and letting anything escape would abort discovery outright. The alias filter in _all_discovery_folders and the target-folder dedup in discover_clients_to_inspect keep the literal-path fallback unchanged, so unresolvable paths stay distinct rather than collapsing into one another. The ValueError arm stays earned on the pipelines side, where the paths come straight from hook payloads and a NUL byte raises it. --- src/agent_scan/agents/base.py | 29 ++++++++++++--- src/agent_scan/hook_events.py | 4 +- src/agent_scan/pipelines.py | 10 ++++- src/agent_scan/utils.py | 13 ------- src/agent_scan/verify_api.py | 12 +++--- tests/unit/test_agent_discovery.py | 59 ++++++++++++++++++++++++++++++ tests/unit/test_hook_events.py | 6 +-- tests/unit/test_utils.py | 37 ------------------- 8 files changed, 102 insertions(+), 68 deletions(-) diff --git a/src/agent_scan/agents/base.py b/src/agent_scan/agents/base.py index a6df1bf1..48569d82 100644 --- a/src/agent_scan/agents/base.py +++ b/src/agent_scan/agents/base.py @@ -32,7 +32,6 @@ ) from agent_scan.signed_binary import check_server_signature from agent_scan.skill_client import inspect_skills_dir -from agent_scan.utils import safe_resolve logger = logging.getLogger(__name__) McpConfigsResult = dict[ @@ -193,8 +192,15 @@ def _scans_own_home(self) -> bool: pass if self.home_directory in candidates: return True - resolved_home = safe_resolve(self.home_directory) - return any(resolved_home == safe_resolve(candidate) for candidate in candidates) + try: + resolved_home = self.home_directory.resolve() + return any(resolved_home == candidate.resolve() for candidate in candidates) + 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: """Enforce a non-empty ``name`` on concrete subclasses. @@ -496,8 +502,21 @@ def _all_target_folders(self) -> list[Path]: def _all_discovery_folders(self) -> list[Path]: """Return project roots and non-alias target roots in stable literal order.""" projects = self._all_project_folders() - resolved_projects = {safe_resolve(project) for project in projects} - targets = [target for target in self._all_target_folders() if safe_resolve(target) not in resolved_projects] + 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._all_target_folders(): + try: + key = target.resolve() + except (OSError, RuntimeError, ValueError): + key = target + if key not in resolved_projects: + targets.append(target) return self._dedupe_folders(iter((*projects, *targets))) @staticmethod diff --git a/src/agent_scan/hook_events.py b/src/agent_scan/hook_events.py index 068986f4..ca4bfd0b 100644 --- a/src/agent_scan/hook_events.py +++ b/src/agent_scan/hook_events.py @@ -12,7 +12,7 @@ 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, platform_client_session +from agent_scan.verify_api import RETRYABLE_TRANSPORT_EXCEPTIONS, backend_client_session from agent_scan.version import version_info @@ -36,7 +36,7 @@ async def _post_hook_event(url: str, body: bytes, headers: dict[str, str], max_r detail = "" for attempt in range(max_retries): try: - async with platform_client_session() as session: + 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. diff --git a/src/agent_scan/pipelines.py b/src/agent_scan/pipelines.py index e6df9769..09512604 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -23,7 +23,7 @@ TokenAndClientInfo, ) from agent_scan.redact import redact_inspected_path -from agent_scan.utils import get_readable_home_directories, safe_resolve +from agent_scan.utils import get_readable_home_directories from agent_scan.verify_api import analyze_machine from agent_scan.well_known_clients import get_well_known_clients @@ -94,7 +94,13 @@ async def discover_clients_to_inspect( seen_target_folders: set[Path] = set() for raw_path in inspect_args.target_folders: target_path = Path(raw_path).expanduser() - key = safe_resolve(target_path) + 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) diff --git a/src/agent_scan/utils.py b/src/agent_scan/utils.py index fd4f8e2c..30323cc9 100644 --- a/src/agent_scan/utils.py +++ b/src/agent_scan/utils.py @@ -191,19 +191,6 @@ def get_push_key(control_servers: list[ControlServer] | list[dict[str, Any]]) -> return None -def safe_resolve(path: Path) -> Path: - """Resolve ``path`` when possible, preserving its literal spelling on failure. - - ``ValueError`` is caught alongside the OS errors because ``Path.resolve()`` - raises it for a path containing a NUL byte, and target folders reach this - helper straight from untrusted hook-payload JSON. - """ - try: - return path.resolve() - except (OSError, RuntimeError, ValueError): - return path - - def get_readable_home_directories(all_users: bool = False) -> list[tuple[Path, str]]: """ Retrieve a list of all human user home directories on the machine diff --git a/src/agent_scan/verify_api.py b/src/agent_scan/verify_api.py index 389ab8d1..545a37f8 100644 --- a/src/agent_scan/verify_api.py +++ b/src/agent_scan/verify_api.py @@ -120,7 +120,7 @@ async def _async_analysis_enabled( """ for attempt in range(max_retries): try: - async with platform_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}, @@ -180,7 +180,7 @@ async def _submit_async_analysis( for attempt in range(max_retries): try: - async with platform_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, @@ -417,10 +417,10 @@ def setup_tcp_connector(skip_ssl_verify: bool = False) -> aiohttp.TCPConnector: return connector -def platform_client_session(trace_configs: list | None = None, skip_ssl_verify: bool = False) -> aiohttp.ClientSession: +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 platform (analysis and Agent Guard hook + 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. """ @@ -455,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 @@ -526,7 +526,7 @@ async def analyze_machine( for attempt in range(max_retries): try: - async with platform_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/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index 75226891..066b3889 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -6133,6 +6133,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 @@ -9243,6 +9275,33 @@ async def test_pipeline_runtime_error_resolving_target_keeps_literal_folder(tmp_ 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]) + + @pytest.mark.asyncio async def test_pipeline_preset_cancel_skips_discovery(tmp_path): from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect diff --git a/tests/unit/test_hook_events.py b/tests/unit/test_hook_events.py index a7f42cbb..f9f40b3e 100644 --- a/tests/unit/test_hook_events.py +++ b/tests/unit/test_hook_events.py @@ -25,7 +25,7 @@ async def __aexit__(self, *exc): class _FakeSession: - """Stand-in for the aiohttp session the shared platform factory builds.""" + """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 @@ -47,7 +47,7 @@ def post(self, url, **kwargs): 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.platform_client_session", return_value=session) + return patch("agent_scan.hook_events.backend_client_session", return_value=session) @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) @@ -79,7 +79,7 @@ def test_sends_existing_hook_wire_contract(client): } -def test_uses_the_shared_platform_session_factory(): +def test_uses_the_shared_backend_session_factory(): """Hook events must ride the same connector as the analysis path (certifi + extra CAs).""" session = _FakeSession() diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index fbc0b4a3..d9f29527 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -2,9 +2,7 @@ import os import subprocess import sys -from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch import pytest @@ -14,7 +12,6 @@ calculate_distance, get_readable_home_directories, get_relative_path, - safe_resolve, suppress_stdout, ) @@ -563,37 +560,3 @@ def fake_run(cmd, **_kwargs): usernames = {u for _p, u in result} assert usernames == {"wsl_alice"}, f"WSL homes must still surface when CIM query fails; got {usernames}" - - -class TestSafeResolve: - """``safe_resolve`` must never raise: callers rely on the literal path as a fallback.""" - - def test_resolves_a_real_path(self, tmp_path): - target = tmp_path / "project" - target.mkdir() - - assert safe_resolve(target) == target.resolve() - - def test_embedded_null_byte_returns_literal_path(self): - """``Path.resolve()`` raises ValueError (not OSError) for a NUL byte. - - Target folders reach ``safe_resolve`` from untrusted hook-payload JSON, so a - payload such as ``{"cwd": "a\\0b"}`` must not abort discovery. - """ - path = Path("a\x00b") - - assert safe_resolve(path) == path - - @pytest.mark.parametrize( - "error", - [ - OSError("stale NFS handle"), - RuntimeError("Symlink loop"), - ValueError("embedded null character in path"), - ], - ) - def test_resolution_failures_return_the_literal_path(self, error, tmp_path): - target = tmp_path / "project" - - with patch.object(Path, "resolve", side_effect=error): - assert safe_resolve(target) == target From cd2a9fb79075e39344f14c7c9c6257dfe05948de Mon Sep 17 00:00:00 2001 From: iamcristi Date: Tue, 25 Aug 2026 16:48:23 +0200 Subject: [PATCH 29/58] refactor: consolidate discovery folder deduplication --- src/agent_scan/agents/base.py | 28 +++++----------------------- tests/unit/test_agent_discovery.py | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/src/agent_scan/agents/base.py b/src/agent_scan/agents/base.py index 48569d82..c55509ad 100644 --- a/src/agent_scan/agents/base.py +++ b/src/agent_scan/agents/base.py @@ -479,29 +479,9 @@ def _discover_target_folders(self) -> list[Path]: """ return list(self.target_folders) - @staticmethod - def _dedupe_folders(folders: Iterator[Path]) -> list[Path]: - """Deduplicate folders by literal path while preserving spelling and order.""" - result: list[Path] = [] - seen: set[Path] = set() - for folder in folders: - if folder in seen: - continue - seen.add(folder) - result.append(folder) - return result - - def _all_project_folders(self) -> list[Path]: - """Return deduplicated roots from the agent's persisted project history.""" - return self._dedupe_folders(iter(self._discover_project_folders())) - - def _all_target_folders(self) -> list[Path]: - """Return deduplicated roots explicitly targeted by this request.""" - return self._dedupe_folders(iter(self._discover_target_folders())) - def _all_discovery_folders(self) -> list[Path]: """Return project roots and non-alias target roots in stable literal order.""" - projects = self._all_project_folders() + projects = self._discover_project_folders() resolved_projects: set[Path] = set() for project in projects: try: @@ -510,14 +490,16 @@ def _all_discovery_folders(self) -> list[Path]: # Unresolvable paths stay distinct under their literal spelling. resolved_projects.add(project) targets: list[Path] = [] - for target in self._all_target_folders(): + 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) - return self._dedupe_folders(iter((*projects, *targets))) + # 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]: diff --git a/tests/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index 066b3889..4ae6e419 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -8956,9 +8956,8 @@ def test_project_and_target_folders_remain_separate(tmp_path): discoverer = ClaudeCodeDiscoverer(tmp_path, [explicit]) - assert discoverer._all_project_folders() == [recorded] + assert discoverer._discover_project_folders() == [recorded] assert discoverer._discover_target_folders() == [explicit] - assert discoverer._all_target_folders() == [explicit] assert discoverer._all_discovery_folders() == [recorded, explicit] @@ -9043,14 +9042,25 @@ def test_all_discovery_folders_dedupes_resolved_paths_and_keeps_recorded_spellin discoverer = ClaudeCodeDiscoverer(tmp_path, [target]) - assert discoverer._all_project_folders() == [recorded_link] - assert discoverer._all_target_folders() == [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 From c823f50c02e62474e8a22d32e6c9d03d0cc3d676 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Tue, 25 Aug 2026 19:37:32 +0200 Subject: [PATCH 30/58] refactor: simplify discovery timeout watchdog --- docs/cli-reference.md | 1 - src/agent_scan/guard.py | 34 +++++++--------------- src/agent_scan/pipelines.py | 9 ------ tests/unit/test_agent_discovery.py | 21 +------------- tests/unit/test_guard.py | 45 +++++++++--------------------- 5 files changed, 24 insertions(+), 86 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 087ff25a..c5dd0edb 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -401,7 +401,6 @@ snyk-agent-scan guard uninstall {claude,cursor,codex,all} [OPTIONS] | `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_BIN` | Agent Scan executable used by the session-start discovery trampoline | -| `AGENT_SCAN_DISCOVERY_TIMEOUT_SECONDS` | Discovery timeout in seconds (default: `60`) | ## Environment variables diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index fab1aa2b..9a63cccb 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -51,7 +51,7 @@ ) _PERMISSION_DENIED = "__permission_denied__" _STDIN_READ_TIMEOUT_SECONDS = 5.0 -_DEFAULT_DISCOVERY_TIMEOUT_SECONDS = 60.0 +_DISCOVERY_TIMEOUT_SECONDS = 60.0 CLAUDE_SETTINGS_PATH = Path.home() / ".claude" / "settings.json" CURSOR_HOOKS_PATH = Path.home() / ".cursor" / "hooks.json" @@ -317,9 +317,13 @@ def _run_install(args) -> None: def _run_with_timeout( func: Callable[[], _T], timeout: float, - cancel: threading.Event | None = None, ) -> _T: - """Run ``func`` on a daemon thread and signal cooperative cancellation on timeout.""" + """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] = [] @@ -333,30 +337,14 @@ def run() -> None: thread.start() thread.join(timeout) if thread.is_alive(): - if cancel is not None: - cancel.set() - thread.join(0.1) raise TimeoutError(f"timed out after {timeout:g}s") if error: raise error[0] return result[0] -def _discovery_timeout_seconds() -> float: - import math - import threading - - try: - value = float(os.environ.get("AGENT_SCAN_DISCOVERY_TIMEOUT_SECONDS", "")) - except ValueError: - return _DEFAULT_DISCOVERY_TIMEOUT_SECONDS - if not math.isfinite(value) or not 0 < value <= threading.TIMEOUT_MAX: - return _DEFAULT_DISCOVERY_TIMEOUT_SECONDS - return value - - def _read_hook_payload() -> str: - """Read hook JSON with a timeout; a blocked stdin read cannot be cancelled cooperatively.""" + """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(): @@ -1211,11 +1199,9 @@ def _discover_servers_payload( discovery_scope=discovery_scope, target_folders=target_folders or [], ) - cancel = threading.Event() clients_to_inspect, _, _ = _run_with_timeout( - lambda: asyncio.run(pipelines.discover_clients_to_inspect(inspect_args, cancel=cancel)), - _discovery_timeout_seconds(), - cancel=cancel, + lambda: asyncio.run(pipelines.discover_clients_to_inspect(inspect_args)), + _DISCOVERY_TIMEOUT_SECONDS, ) return _servers_discovered_entries(clients_to_inspect) diff --git a/src/agent_scan/pipelines.py b/src/agent_scan/pipelines.py index 09512604..40723964 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -1,7 +1,6 @@ import getpass import logging import os -import threading from pathlib import Path from pydantic import BaseModel, Field @@ -61,8 +60,6 @@ class PushArgs(BaseModel): async def discover_clients_to_inspect( inspect_args: InspectArgs, - *, - cancel: threading.Event | None = None, ) -> tuple[list[ClientToInspect], list[InspectedPath], list[str]]: """ Discover the clients/configs that would be inspected, without actually @@ -111,8 +108,6 @@ async def discover_clients_to_inspect( # Phase A — legacy path. Runs for EVERY well-known client including Claude Code. for client in get_well_known_clients(): - if cancel is not None and cancel.is_set(): - break ctis = await get_mcp_config_per_client(client, home_dirs_with_users, scope=inspect_args.discovery_scope) if ctis: clients_to_inspect.extend(ctis) @@ -121,11 +116,7 @@ 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: - if cancel is not None and cancel.is_set(): - break for discoverer in find_discoverers(home_directory, target_folders=target_folders): - if cancel is not None and cancel.is_set(): - break try: cti = discoverer.discover(inspect_args.discovery_scope) except Exception: diff --git a/tests/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index 4ae6e419..89fd61b3 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -2,9 +2,8 @@ import json import sys -import threading from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -9310,21 +9309,3 @@ async def test_pipeline_null_byte_target_folder_is_skipped_without_aborting(tmp_ ) find.assert_called_once_with(home, target_folders=[good]) - - -@pytest.mark.asyncio -async def test_pipeline_preset_cancel_skips_discovery(tmp_path): - from agent_scan.pipelines import InspectArgs, discover_clients_to_inspect - - cancel = threading.Event() - cancel.set() - discoverer = MagicMock() - - with ( - patch("agent_scan.pipelines.get_readable_home_directories", return_value=[(tmp_path, "alice")]), - patch("agent_scan.pipelines.get_well_known_clients", return_value=[]), - patch("agent_scan.pipelines.find_discoverers", return_value=[discoverer]), - ): - await discover_clients_to_inspect(InspectArgs(timeout=0, tokens=[], paths=[]), cancel=cancel) - - discoverer.discover.assert_not_called() diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 1b37a0fb..ea0c5cb7 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -3213,6 +3213,7 @@ def test_uses_current_user_server_only_discovery(self): 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): @@ -3235,26 +3236,8 @@ def test_threads_target_folders_to_inspect_args(self): assert args.target_folders == ["/repo/one", "/repo/two"] assert result == [] - @pytest.mark.parametrize( - "raw_value,expected", - [ - (None, 60.0), - ("garbage", 60.0), - ("0", 60.0), - ("-1", 60.0), - ("nan", 60.0), - ("inf", 60.0), - ("1e100", 60.0), - ("2.5", 2.5), - ], - ) - def test_discovery_timeout_environment_parsing(self, raw_value, expected, monkeypatch): - if raw_value is None: - monkeypatch.delenv("AGENT_SCAN_DISCOVERY_TIMEOUT_SECONDS", raising=False) - else: - monkeypatch.setenv("AGENT_SCAN_DISCOVERY_TIMEOUT_SECONDS", raw_value) - - assert guard_module._discovery_timeout_seconds() == expected + def test_discovery_timeout_is_60_seconds(self): + assert guard_module._DISCOVERY_TIMEOUT_SECONDS == 60.0 class TestInvokeHookScript: @@ -3335,19 +3318,17 @@ def test_nonzero_exit_returns_stderr(self): assert result == (False, "bad request") -def test_run_with_timeout_signals_and_joins_cooperative_worker(): - cancel = threading.Event() - stopped = threading.Event() +def test_run_with_timeout_raises_when_worker_exceeds_deadline(): + stop = threading.Event() def worker(): - cancel.wait() - stopped.set() - - with pytest.raises(TimeoutError, match="timed out"): - guard_module._run_with_timeout(worker, 0.01, cancel=cancel) + stop.wait(5) - assert cancel.is_set() - assert stopped.wait(0.2) + try: + with pytest.raises(TimeoutError, match="timed out"): + guard_module._run_with_timeout(worker, 0.01) + finally: + stop.set() class TestSendServersDiscoveredEvent: @@ -3472,13 +3453,13 @@ def test_discovery_timeout_warns_without_sending(self): import asyncio import time as test_time - async def slow_discovery(_inspect_args, *, cancel=None): + 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", return_value=0.01), + patch(f"{_G}._DISCOVERY_TIMEOUT_SECONDS", 0.01), patch(f"{_G}.send_hook_event") as send, patch(f"{_G}.rich") as rich_mock, ): From 304aab9b6aa1d77e910b7b5e352f70ce20f697f5 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Tue, 25 Aug 2026 19:52:54 +0200 Subject: [PATCH 31/58] fix: address discovery review findings --- src/agent_scan/guard.py | 43 +++++++++++++++++++++++++----- src/agent_scan/pipelines.py | 7 ++++- tests/unit/test_agent_discovery.py | 34 +++++++++++++++++++++++ tests/unit/test_guard.py | 37 +++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 8 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 9a63cccb..3114d13c 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -470,6 +470,11 @@ def _discover_script_path(config_path: Path) -> Path: return config_path.parent / "hooks" / name +def _hook_script_path(config_path: Path) -> Path: + name = "snyk-agent-guard.ps1" if IS_WINDOWS else "snyk-agent-guard.sh" + return config_path.parent / "hooks" / name + + def _install_hooks( client: str, hook_client: str, @@ -489,8 +494,9 @@ def _install_hooks( push_key_changed = bool(old_push_key) and old_push_key != push_key is_codex_requirements = _is_codex_requirements_toml(config_path) + hook_script_backup = _snapshot_hook_script(_hook_script_path(config_path)) discover_script_path = _discover_script_path(config_path) - discover_script_existed = discover_script_path.exists() + discover_script_backup = _snapshot_hook_script(discover_script_path) ( dest_path, script_existed, @@ -543,10 +549,12 @@ def _install_hooks( discover_new_checksum=discover_new_checksum, machine_id=machine_id, ): - if not script_existed: - dest_path.unlink(missing_ok=True) - if not discover_script_existed: - discover_script_path.unlink(missing_ok=True) + _restore_hook_script(dest_path, hook_script_backup, existed=script_existed) + _restore_hook_script( + discover_script_path, + discover_script_backup, + existed=discover_current_checksum is not None, + ) rich.print("[bold red]Aborting install \u2014 test event failed.[/bold red]") raise SystemExit(1) @@ -1725,6 +1733,27 @@ class _HookScripts(NamedTuple): discover_new_checksum: str | None = None +class _HookScriptBackup(NamedTuple): + content: bytes + mode: int + + +def _snapshot_hook_script(path: Path) -> _HookScriptBackup | None: + """Capture an existing hook script so a failed upgrade can restore it.""" + if not path.exists(): + return None + return _HookScriptBackup(path.read_bytes(), stat.S_IMODE(path.stat().st_mode)) + + +def _restore_hook_script(path: Path, backup: _HookScriptBackup | None, *, existed: bool) -> None: + """Roll back a copied hook script after its pre-commit test event fails.""" + if backup is not None: + path.write_bytes(backup.content) + path.chmod(backup.mode) + elif not existed: + path.unlink(missing_ok=True) + + def _copy_hook_script(config_path: Path, *, include_discover: bool = True) -> _HookScripts: """Copy bundled hook scripts to a hooks/ dir next to the config file. @@ -1734,8 +1763,8 @@ def _copy_hook_script(config_path: Path, *, include_discover: bool = True) -> _H 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 + dest = _hook_script_path(config_path) + script_name = dest.name existed = dest.exists() current_checksum: str | None = None diff --git a/src/agent_scan/pipelines.py b/src/agent_scan/pipelines.py index 40723964..56cad822 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -101,7 +101,12 @@ async def discover_clients_to_inspect( if key in seen_target_folders: continue seen_target_folders.add(key) - if not key.exists(): + 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) diff --git a/tests/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index 89fd61b3..903c19b3 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -9232,6 +9232,40 @@ async def test_pipeline_skips_missing_target_folder_with_warning(tmp_path, caplo 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 diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index ea0c5cb7..c3a453d3 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -2908,6 +2908,43 @@ def test_test_event_failure_keeps_existing_script(self, ctx, tmp_path): self._call(tmp_path, minted=True, config_exists=True) ctx["dest"].unlink.assert_not_called() + def test_test_event_failure_restores_overwritten_existing_scripts(self, ctx, tmp_path): + hooks_dir = tmp_path / "hooks" + hooks_dir.mkdir() + main_script = hooks_dir / "snyk-agent-guard.sh" + discover_script = hooks_dir / "snyk-agent-guard-discover.sh" + main_script.write_bytes(b"old forwarder\n") + discover_script.write_bytes(b"old discovery\n") + main_script.chmod(0o600) + discover_script.chmod(0o640) + + def overwrite_scripts(_config_path, *, include_discover): + assert include_discover is True + main_script.write_bytes(b"new forwarder requiring MACHINE_ID\n") + discover_script.write_bytes(b"new discovery\n") + main_script.chmod(0o755) + discover_script.chmod(0o755) + return ( + main_script, + True, + True, + _CURRENT_CHECKSUM, + _NEW_CHECKSUM, + "discover-current", + "discover-new", + ) + + ctx["copy"].side_effect = overwrite_scripts + ctx["test_event"].return_value = False + + with patch(f"{_G}.IS_WINDOWS", False), pytest.raises(SystemExit): + self._call(tmp_path, minted=True, config_exists=True) + + assert main_script.read_bytes() == b"old forwarder\n" + assert discover_script.read_bytes() == b"old discovery\n" + assert main_script.stat().st_mode & 0o777 == 0o600 + assert discover_script.stat().st_mode & 0o777 == 0o640 + def test_test_event_failure_does_not_write_config(self, ctx, tmp_path): ctx["test_event"].return_value = False with pytest.raises(SystemExit): From d8b6860ae809ea14cd4cd87d3cd35ede8cc6e07d Mon Sep 17 00:00:00 2001 From: iamcristi Date: Tue, 25 Aug 2026 19:58:12 +0200 Subject: [PATCH 32/58] test: make rollback assertion cross-platform --- tests/unit/test_guard.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index c3a453d3..68b1c24e 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -2942,8 +2942,9 @@ def overwrite_scripts(_config_path, *, include_discover): assert main_script.read_bytes() == b"old forwarder\n" assert discover_script.read_bytes() == b"old discovery\n" - assert main_script.stat().st_mode & 0o777 == 0o600 - assert discover_script.stat().st_mode & 0o777 == 0o640 + if sys.platform != "win32": + assert main_script.stat().st_mode & 0o777 == 0o600 + assert discover_script.stat().st_mode & 0o777 == 0o640 def test_test_event_failure_does_not_write_config(self, ctx, tmp_path): ctx["test_event"].return_value = False From 28bc28eabffc4887f615e3b95db641a959b546e1 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 08:12:14 +0200 Subject: [PATCH 33/58] fix: make guard tests portable on Windows --- src/agent_scan/utils.py | 14 +++++++++----- tests/unit/test_guard.py | 7 +++++-- tests/unit/test_utils.py | 9 +++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/agent_scan/utils.py b/src/agent_scan/utils.py index 30323cc9..05fe3ecc 100644 --- a/src/agent_scan/utils.py +++ b/src/agent_scan/utils.py @@ -63,11 +63,15 @@ def ensure_unicode_console() -> None: 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("\\", "/") + expanded_path = os.path.expanduser(path).replace("\\", "/") + home_dir = os.path.expanduser("~").replace("\\", "/").rstrip("/") + compared_path = expanded_path.casefold() if sys.platform == "win32" else expanded_path + compared_home = home_dir.casefold() if sys.platform == "win32" else home_dir + if compared_path == compared_home: + return "~" + if compared_home and compared_path.startswith(compared_home + "/"): + return "~" + expanded_path[len(home_dir) :] + return expanded_path except Exception: return path.replace("\\", "/") diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 68b1c24e..9930b9d1 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -259,14 +259,16 @@ def test_machine_id_powershell_escapes_single_quotes(self): 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", - Path("C:/Users/O'Brien/hook.ps1"), + script_path, "codex", ) - assert "-File 'C:/Users/O''Brien/hook.ps1'" in cmd + 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 @@ -2211,6 +2213,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( diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index d9f29527..fbd2264d 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -35,6 +35,15 @@ 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, + ) + + assert get_relative_path("C:/Users/runneradmin/.claude") == "~/.claude" + def test_empty_path(self): result = get_relative_path("") assert result == "" From a9b5c7a8e474540da6b6658045c7b3642392f574 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 08:18:07 +0200 Subject: [PATCH 34/58] fix: preserve relative path semantics --- src/agent_scan/utils.py | 24 +++++++++++++++++------- tests/unit/test_utils.py | 22 +++++++++++++++++++++- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/agent_scan/utils.py b/src/agent_scan/utils.py index 05fe3ecc..f0e62776 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 @@ -63,15 +64,24 @@ def ensure_unicode_console() -> None: def get_relative_path(path: str) -> str: try: + original_path = path.replace("\\", "/") expanded_path = os.path.expanduser(path).replace("\\", "/") home_dir = os.path.expanduser("~").replace("\\", "/").rstrip("/") - compared_path = expanded_path.casefold() if sys.platform == "win32" else expanded_path - compared_home = home_dir.casefold() if sys.platform == "win32" else home_dir - if compared_path == compared_home: - return "~" - if compared_home and compared_path.startswith(compared_home + "/"): - return "~" + expanded_path[len(home_dir) :] - return expanded_path + 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/tests/unit/test_utils.py b/tests/unit/test_utils.py index fbd2264d..bb6246bf 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -41,8 +41,28 @@ def test_windows_home_path_with_mixed_separators(self, monkeypatch): "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" + 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("") From d290a9bfa93727cbb1a99b159854524c8041257c Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 09:13:19 +0200 Subject: [PATCH 35/58] refactor: single machine-id spelling for guard install guard install accepted --control-identifier as an alias for --machine-id, which made that option string mean control_identifier on scan/inspect/evo and machine_id here. explicitly_provided_dests keys a flat option-string to dest map, so the collision resolved to whichever action the walk visited last -- scan --config-file c.yaml --control-server URL --control-identifier ID stopped registering control_identifier as explicit and let the config file's control_servers override the command line. Keep --machine-id only. --control-identifier is already deprecated in favour of it, and guard install could never warn about the spelling because its dest is machine_id. explicitly_provided_dests goes back to walking every action, and a test over the real parser now asserts no option string maps to two dests. --- docs/cli-reference.md | 2 +- src/agent_scan/cli.py | 41 +++++----------- tests/unit/test_cli_config_file.py | 76 +++++++++--------------------- tests/unit/test_guard.py | 22 +++++++-- 4 files changed, 54 insertions(+), 87 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c5dd0edb..db0fa641 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -361,7 +361,7 @@ Installation also configures a fire-and-forget session-start hook that reports d | --- | --- | --- | --- | | `--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`, `--control-identifier ID` | string | — | Required non-anonymous machine identifier sent in the `X-User` header's `identifier` field. May instead be set with `MACHINE_ID`. | +| `--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.** | diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index c04cc6f8..d2d16713 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -228,38 +228,23 @@ def _iter_all_actions(parser: argparse.ArgumentParser): yield action -def _iter_active_actions(parser: argparse.ArgumentParser, args: argparse.Namespace): - """Yield actions from the subparser path argparse already resolved into ``args``. - - Each ``add_subparsers`` call names a dest (``command``, ``guard_command``), so the - selected subcommand can be read straight off the namespace instead of re-walking - argv. An unset dest means that level was not reached, and the walk stops there. - """ - for action in parser._actions: - if isinstance(action, argparse._SubParsersAction): - selected = getattr(args, action.dest, None) - chosen = action.choices.get(selected) if isinstance(selected, str) else None - if chosen is not None: - yield from _iter_active_actions(chosen, args) - else: - yield action - - -def explicitly_provided_dests(parser: argparse.ArgumentParser, args: argparse.Namespace, argv: list[str]) -> set[str]: +def explicitly_provided_dests(parser: argparse.ArgumentParser, argv: list[str]) -> set[str]: """ Return the set of argument ``dest`` names the user passed explicitly on the command line. We scan the raw ``argv`` rather than reading the parsed namespace because argparse cannot distinguish "flag omitted" (dest holds its default) from "flag passed - with a value equal to its default". ``args`` is used only to know which subparser - is active, which is what disambiguates an option string defined on more than one - subcommand. Both ``--flag value`` and ``--flag=value`` spellings are recognized, - as are the two option strings of a BooleanOptionalAction (``--skills`` / - ``--no-skills`` both map to ``skills``). + with a value equal to its default". Both ``--flag value`` and ``--flag=value`` + spellings are recognized, as are the two option strings of a BooleanOptionalAction + (``--skills`` / ``--no-skills`` both map to ``skills``). + + An option string reused across subcommands must resolve to the same dest, since + this map is flat and carries no notion of which subcommand is active. A test in + tests/unit/test_cli_config_file.py enforces that invariant over the real parser. """ option_to_dest: dict[str, str] = {} - for action in _iter_active_actions(parser, args): + for action in _iter_all_actions(parser): for option in action.option_strings: option_to_dest[option] = action.dest @@ -436,7 +421,7 @@ def apply_config_file(parser: argparse.ArgumentParser, args: argparse.Namespace, return config = load_config_file(config_path) - explicit = explicitly_provided_dests(parser, args, argv) + explicit = explicitly_provided_dests(parser, argv) # The positional ``files`` list has no option string, so treat any positional # value present on the CLI as an explicit override of the YAML ``files``. @@ -966,15 +951,11 @@ def main(): ) guard_install_parser.add_argument( "--machine-id", - "--control-identifier", 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 " - "(accepts --control-identifier for symmetry with scan)" - ), + help="Required non-anonymous identifier for this machine, sent as the X-User identifier on hook events", ) guard_install_parser.add_argument( "--test", diff --git a/tests/unit/test_cli_config_file.py b/tests/unit/test_cli_config_file.py index c34c638b..93f46d91 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, @@ -42,8 +45,8 @@ def _parse(argv: list[str]) -> tuple[argparse.ArgumentParser, argparse.Namespace def _provided(parser: argparse.ArgumentParser, argv: list[str]) -> set[str]: - """Let argparse resolve the subparser path, then ask which dests were explicit.""" - return explicitly_provided_dests(parser, parser.parse_args(argv), argv) + """Ask which dests ``argv`` set explicitly.""" + return explicitly_provided_dests(parser, argv) def _write_yaml(tmp_path, text: str) -> str: @@ -97,59 +100,26 @@ def test_double_dash_stops_option_detection(self): assert "json" not in _provided(_build_parser(), ["scan", "--", "--json"]) assert "json" in _provided(_build_parser(), ["scan", "--json"]) - def test_uses_destination_from_active_subparser_when_option_aliases_collide(self): - parser = _build_parser() - subparsers = next(action for action in parser._actions if isinstance(action, argparse._SubParsersAction)) - guard_parser = subparsers.add_parser("guard", allow_abbrev=False) - guard_subparsers = guard_parser.add_subparsers(dest="guard_command") - guard_install_parser = guard_subparsers.add_parser("install", allow_abbrev=False) - guard_install_parser.add_argument("--machine-id", "--control-identifier", dest="machine_id", default=None) - - provided = _provided( - parser, - ["scan", "--control-server", "https://example.com", "--control-identifier", "legacy-id"], - ) - - assert "control_identifier" in provided - assert "machine_id" not in provided - - def test_uses_destination_from_nested_subparser(self): - """The same alias resolves to guard install's dest when that is the active path.""" - parser = _build_parser() - subparsers = next(action for action in parser._actions if isinstance(action, argparse._SubParsersAction)) - guard_parser = subparsers.add_parser("guard", allow_abbrev=False) - guard_subparsers = guard_parser.add_subparsers(dest="guard_command") - guard_install_parser = guard_subparsers.add_parser("install", allow_abbrev=False) - guard_install_parser.add_argument("client") - guard_install_parser.add_argument("--machine-id", "--control-identifier", dest="machine_id", default=None) - - provided = _provided(parser, ["guard", "install", "claude", "--control-identifier", "m1"]) - - assert "machine_id" in provided - assert "control_identifier" not in provided - - def test_root_option_value_is_not_mistaken_for_subcommand(self): - parser = argparse.ArgumentParser(allow_abbrev=False) - parser.add_argument("--config-file") - subparsers = parser.add_subparsers(dest="command") - scan_parser = subparsers.add_parser("scan", allow_abbrev=False) - scan_parser.add_argument("--control-identifier") - guard_parser = subparsers.add_parser("guard", allow_abbrev=False) - guard_subparsers = guard_parser.add_subparsers(dest="guard_command") - guard_subparsers.add_parser("install", allow_abbrev=False) - - provided = _provided(parser, ["--config-file", "guard", "scan", "--control-identifier", "x"]) - - assert "control_identifier" in provided - - def test_no_subcommand_yields_only_root_options(self): - """A namespace whose subparser dest is unset must not crash the action walk.""" - parser = _build_parser() - parser.add_argument("--top-level") + 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] = [] - provided = explicitly_provided_dests(parser, parser.parse_args([]), ["--top-level", "x"]) + 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() - assert provided == {"top_level"} + 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 9930b9d1..6b11c8ef 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -3515,11 +3515,10 @@ async def slow_discovery(_inspect_args): class TestGuardInstallMachineIdCli: - @pytest.mark.parametrize("flag", ["--machine-id", "--control-identifier"]) - def test_guard_install_accepts_machine_id_aliases(self, flag, monkeypatch): + def test_guard_install_accepts_machine_id(self, monkeypatch): from agent_scan import cli - monkeypatch.setattr(sys, "argv", ["agent-scan", "guard", "install", "claude", flag, "machine-42"]) + 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() @@ -3527,6 +3526,23 @@ def test_guard_install_accepts_machine_id_aliases(self, flag, monkeypatch): 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 8c8645996f3a625fb9d68055cbd1aade71691edb Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 09:19:18 +0200 Subject: [PATCH 36/58] revert: drop end-of-options guard from explicitly_provided_dests The `--` break and its test are unrelated to guard server discovery. The argv scan mistaking a post-`--` positional for a flag predates this branch, so the fix belongs on its own change against main rather than riding along here. --- src/agent_scan/cli.py | 2 -- tests/unit/test_cli_config_file.py | 4 ---- 2 files changed, 6 deletions(-) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index d2d16713..ad2c5588 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -250,8 +250,6 @@ def explicitly_provided_dests(parser: argparse.ArgumentParser, argv: list[str]) provided: set[str] = set() for token in argv: - if token == "--": - break option = token.split("=", 1)[0] dest = option_to_dest.get(option) if dest is not None: diff --git a/tests/unit/test_cli_config_file.py b/tests/unit/test_cli_config_file.py index 93f46d91..90916ef9 100644 --- a/tests/unit/test_cli_config_file.py +++ b/tests/unit/test_cli_config_file.py @@ -96,10 +96,6 @@ def test_boolean_optional_both_spellings_map_to_same_dest(self): assert "skills" in _provided(_build_parser(), ["scan", "--no-skills"]) assert "skills" in _provided(_build_parser(), ["scan", "--skills"]) - def test_double_dash_stops_option_detection(self): - assert "json" not in _provided(_build_parser(), ["scan", "--", "--json"]) - assert "json" in _provided(_build_parser(), ["scan", "--json"]) - 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 From a94fb78e69569b5d0e2642caf8098ba990387252 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 09:22:29 +0200 Subject: [PATCH 37/58] docs: restore explicitly_provided_dests docstring Reverts wording churn and an invariant note that duplicated the enforcing test's own docstring. The function now matches main verbatim. --- src/agent_scan/cli.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index ad2c5588..b9ac647c 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -233,15 +233,11 @@ def explicitly_provided_dests(parser: argparse.ArgumentParser, argv: list[str]) Return the set of argument ``dest`` names the user passed explicitly on the command line. - We scan the raw ``argv`` rather than reading the parsed namespace because argparse + We inspect the raw ``argv`` rather than the parsed namespace because argparse cannot distinguish "flag omitted" (dest holds its default) from "flag passed with a value equal to its default". Both ``--flag value`` and ``--flag=value`` - spellings are recognized, as are the two option strings of a BooleanOptionalAction - (``--skills`` / ``--no-skills`` both map to ``skills``). - - An option string reused across subcommands must resolve to the same dest, since - this map is flat and carries no notion of which subcommand is active. A test in - tests/unit/test_cli_config_file.py enforces that invariant over the real parser. + spellings are recognized, as are the two option strings of a + BooleanOptionalAction (``--skills`` / ``--no-skills`` both map to ``skills``). """ option_to_dest: dict[str, str] = {} for action in _iter_all_actions(parser): From 5a7d964c0156ab30b2f77b03463d185ddf01f1f2 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 09:44:11 +0200 Subject: [PATCH 38/58] revert: drop hook-script snapshot rollback from guard install The install failure path snapshotted each hook script's bytes and mode before the copy and wrote them back when the test event failed, covering scripts that merely got overwritten. main only deletes a script the install had just created, so this widened the abort contract well past the PR's scope. Back to main's rule, applied to both scripts: a newly created forwarder or discovery trampoline is unlinked on abort, a pre-existing one is left alone. Removes _HookScriptBackup, _snapshot_hook_script, _restore_hook_script and _hook_script_path, whose only purpose was the snapshot. --- src/agent_scan/guard.py | 43 +++++++--------------------------------- tests/unit/test_guard.py | 38 ----------------------------------- 2 files changed, 7 insertions(+), 74 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 3114d13c..9a63cccb 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -470,11 +470,6 @@ def _discover_script_path(config_path: Path) -> Path: return config_path.parent / "hooks" / name -def _hook_script_path(config_path: Path) -> Path: - name = "snyk-agent-guard.ps1" if IS_WINDOWS else "snyk-agent-guard.sh" - return config_path.parent / "hooks" / name - - def _install_hooks( client: str, hook_client: str, @@ -494,9 +489,8 @@ def _install_hooks( push_key_changed = bool(old_push_key) and old_push_key != push_key is_codex_requirements = _is_codex_requirements_toml(config_path) - hook_script_backup = _snapshot_hook_script(_hook_script_path(config_path)) discover_script_path = _discover_script_path(config_path) - discover_script_backup = _snapshot_hook_script(discover_script_path) + discover_script_existed = discover_script_path.exists() ( dest_path, script_existed, @@ -549,12 +543,10 @@ def _install_hooks( discover_new_checksum=discover_new_checksum, machine_id=machine_id, ): - _restore_hook_script(dest_path, hook_script_backup, existed=script_existed) - _restore_hook_script( - discover_script_path, - discover_script_backup, - existed=discover_current_checksum is not None, - ) + if not 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) @@ -1733,27 +1725,6 @@ class _HookScripts(NamedTuple): discover_new_checksum: str | None = None -class _HookScriptBackup(NamedTuple): - content: bytes - mode: int - - -def _snapshot_hook_script(path: Path) -> _HookScriptBackup | None: - """Capture an existing hook script so a failed upgrade can restore it.""" - if not path.exists(): - return None - return _HookScriptBackup(path.read_bytes(), stat.S_IMODE(path.stat().st_mode)) - - -def _restore_hook_script(path: Path, backup: _HookScriptBackup | None, *, existed: bool) -> None: - """Roll back a copied hook script after its pre-commit test event fails.""" - if backup is not None: - path.write_bytes(backup.content) - path.chmod(backup.mode) - elif not existed: - path.unlink(missing_ok=True) - - def _copy_hook_script(config_path: Path, *, include_discover: bool = True) -> _HookScripts: """Copy bundled hook scripts to a hooks/ dir next to the config file. @@ -1763,8 +1734,8 @@ def _copy_hook_script(config_path: Path, *, include_discover: bool = True) -> _H dest_dir = config_path.parent / "hooks" dest_dir.mkdir(parents=True, exist_ok=True) - dest = _hook_script_path(config_path) - script_name = dest.name + 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 diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 6b11c8ef..e9351e4c 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -2911,44 +2911,6 @@ def test_test_event_failure_keeps_existing_script(self, ctx, tmp_path): self._call(tmp_path, minted=True, config_exists=True) ctx["dest"].unlink.assert_not_called() - def test_test_event_failure_restores_overwritten_existing_scripts(self, ctx, tmp_path): - hooks_dir = tmp_path / "hooks" - hooks_dir.mkdir() - main_script = hooks_dir / "snyk-agent-guard.sh" - discover_script = hooks_dir / "snyk-agent-guard-discover.sh" - main_script.write_bytes(b"old forwarder\n") - discover_script.write_bytes(b"old discovery\n") - main_script.chmod(0o600) - discover_script.chmod(0o640) - - def overwrite_scripts(_config_path, *, include_discover): - assert include_discover is True - main_script.write_bytes(b"new forwarder requiring MACHINE_ID\n") - discover_script.write_bytes(b"new discovery\n") - main_script.chmod(0o755) - discover_script.chmod(0o755) - return ( - main_script, - True, - True, - _CURRENT_CHECKSUM, - _NEW_CHECKSUM, - "discover-current", - "discover-new", - ) - - ctx["copy"].side_effect = overwrite_scripts - ctx["test_event"].return_value = False - - with patch(f"{_G}.IS_WINDOWS", False), pytest.raises(SystemExit): - self._call(tmp_path, minted=True, config_exists=True) - - assert main_script.read_bytes() == b"old forwarder\n" - assert discover_script.read_bytes() == b"old discovery\n" - if sys.platform != "win32": - assert main_script.stat().st_mode & 0o777 == 0o600 - assert discover_script.stat().st_mode & 0o777 == 0o640 - def test_test_event_failure_does_not_write_config(self, ctx, tmp_path): ctx["test_event"].return_value = False with pytest.raises(SystemExit): From 875e39da36fb3d10b96dfb9af2ce4e60265179d1 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 09:52:18 +0200 Subject: [PATCH 39/58] fix: send an honest User-Agent for direct hook events The Python delivery path claimed to be snyk-agent-guard.sh/.ps1, so backend telemetry could not tell shell-script traffic apart from in-process sends. Identify the actual sender instead. --- src/agent_scan/hook_events.py | 4 +--- tests/unit/test_hook_events.py | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/agent_scan/hook_events.py b/src/agent_scan/hook_events.py index ca4bfd0b..7f8d0fb4 100644 --- a/src/agent_scan/hook_events.py +++ b/src/agent_scan/hook_events.py @@ -5,7 +5,6 @@ import asyncio import base64 import json -import sys from typing import NamedTuple import aiohttp @@ -83,10 +82,9 @@ def send_hook_event( ) encoded_payload = base64.b64encode(payload.encode()).decode() body = f"base64:{encoded_payload}".encode() - script_extension = "ps1" if sys.platform == "win32" else "sh" url = f"{base_url.rstrip('/')}{client.endpoint}?version={HOOK_VERSION}" headers = { - "User-Agent": f"snyk/snyk-agent-guard.{script_extension} Agent Scan v{version_info}", + "User-Agent": f"snyk/agent-scan Agent Scan v{version_info}", "X-User": x_user, "Content-Type": "text/plain", "X-Client-Id": push_key, diff --git a/tests/unit/test_hook_events.py b/tests/unit/test_hook_events.py index f9f40b3e..d8e11b0d 100644 --- a/tests/unit/test_hook_events.py +++ b/tests/unit/test_hook_events.py @@ -11,6 +11,7 @@ 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: @@ -71,7 +72,7 @@ def test_sends_existing_hook_wire_contract(client): headers = post["headers"] assert headers["Content-Type"] == "text/plain" assert headers["X-Client-Id"] == "push-key" - assert "Agent Scan v" in headers["User-Agent"] + 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", From d05839d20c6c0a6dc40fedb96907203f75c63d03 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 10:00:34 +0200 Subject: [PATCH 40/58] refactor: take AGENT_SCAN_BIN from the environment only _agent_scan_bin guessed the CLI location from sys.frozen, argv[0] and the interpreter's sibling console script when AGENT_SCAN_BIN was unset. The caller supplies the path instead, so the guessing is gone: the value is read from the environment at install time and baked into the hook command as before, and an unset variable leaves it out so the trampolines fall back to PATH. --- src/agent_scan/guard.py | 17 +-------- tests/unit/test_guard.py | 79 +--------------------------------------- 2 files changed, 4 insertions(+), 92 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 9a63cccb..0dadbf23 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -1601,21 +1601,8 @@ def _build_hook_command( def _agent_scan_bin() -> str | None: - if "AGENT_SCAN_BIN" in os.environ: - return os.environ["AGENT_SCAN_BIN"] - if getattr(sys, "frozen", False): - return str(Path(sys.executable).resolve()) - - names = ("snyk-agent-scan.exe", "snyk-agent-scan") if IS_WINDOWS else ("snyk-agent-scan",) - invoked_path = Path(sys.argv[0]) - if invoked_path.name in names and invoked_path.is_file() and os.access(invoked_path, os.X_OK): - return str(invoked_path.resolve()) - - for name in names: - console_script = Path(sys.executable).parent / name - if console_script.is_file() and os.access(console_script, os.X_OK): - return str(console_script.resolve()) - return None + """The binary the session-start hook should invoke, or None to let it use PATH.""" + return os.environ.get("AGENT_SCAN_BIN") def _build_discover_hook_command( diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index e9351e4c..563f5b37 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -297,88 +297,13 @@ def test_roundtrip_extract(self): class TestAgentScanBin: - def test_environment_override_wins(self, monkeypatch): + def test_uses_environment_value(self, monkeypatch): monkeypatch.setenv("AGENT_SCAN_BIN", "custom agent scan") - monkeypatch.setattr(sys, "frozen", True, raising=False) - monkeypatch.setattr(sys, "executable", "/ignored/frozen-binary") assert guard_module._agent_scan_bin() == "custom agent scan" - def test_frozen_binary_uses_resolved_executable(self, tmp_path, monkeypatch): - executable = tmp_path / "dist" / "agent-scan" + def test_returns_none_when_environment_unset(self, monkeypatch): monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) - monkeypatch.setattr(sys, "frozen", True, raising=False) - monkeypatch.setattr(sys, "executable", str(executable)) - - assert guard_module._agent_scan_bin() == str(executable.resolve()) - - def test_console_script_uses_resolved_argv_zero(self, tmp_path, monkeypatch): - executable = tmp_path / "snyk-agent-scan" - executable.write_text("#!/bin/sh\n") - executable.chmod(0o755) - monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) - monkeypatch.setattr(sys, "frozen", False, raising=False) - monkeypatch.setattr(sys, "argv", [str(executable)]) - monkeypatch.setattr(sys, "executable", str(tmp_path / "python")) - - assert guard_module._agent_scan_bin() == str(executable.resolve()) - - def test_venv_console_script_sibling_is_used_for_dev_invocation(self, tmp_path, monkeypatch): - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - executable = bin_dir / "snyk-agent-scan" - executable.write_text("#!/bin/sh\n") - executable.chmod(0o755) - monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) - monkeypatch.setattr(sys, "frozen", False, raising=False) - monkeypatch.setattr(sys, "argv", [str(tmp_path / "src" / "agent_scan" / "cli.py")]) - monkeypatch.setattr(sys, "executable", str(bin_dir / "python")) - - assert guard_module._agent_scan_bin() == str(executable.resolve()) - - def test_windows_console_script_uses_resolved_argv_zero(self, tmp_path, monkeypatch): - executable = tmp_path / "snyk-agent-scan.exe" - executable.write_text("binary") - executable.chmod(0o755) - monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) - monkeypatch.setattr(sys, "frozen", False, raising=False) - monkeypatch.setattr(sys, "argv", [str(executable)]) - monkeypatch.setattr(sys, "executable", str(tmp_path / "python.exe")) - - with patch(f"{_G}.IS_WINDOWS", True): - assert guard_module._agent_scan_bin() == str(executable.resolve()) - - def test_windows_venv_console_script_sibling_is_used(self, tmp_path, monkeypatch): - scripts_dir = tmp_path / "Scripts" - scripts_dir.mkdir() - executable = scripts_dir / "snyk-agent-scan.exe" - executable.write_text("binary") - executable.chmod(0o755) - monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) - monkeypatch.setattr(sys, "frozen", False, raising=False) - monkeypatch.setattr(sys, "argv", [str(tmp_path / "src" / "agent_scan" / "cli.py")]) - monkeypatch.setattr(sys, "executable", str(scripts_dir / "python.exe")) - - with patch(f"{_G}.IS_WINDOWS", True): - assert guard_module._agent_scan_bin() == str(executable.resolve()) - - def test_posix_refuses_windows_console_script_name(self, tmp_path, monkeypatch): - executable = tmp_path / "snyk-agent-scan.exe" - executable.write_text("binary") - executable.chmod(0o755) - monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) - monkeypatch.setattr(sys, "frozen", False, raising=False) - monkeypatch.setattr(sys, "argv", [str(executable)]) - monkeypatch.setattr(sys, "executable", str(tmp_path / "python")) - - with patch(f"{_G}.IS_WINDOWS", False): - assert guard_module._agent_scan_bin() is None - - def test_returns_none_when_no_executable_matches(self, tmp_path, monkeypatch): - monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) - monkeypatch.setattr(sys, "frozen", False, raising=False) - monkeypatch.setattr(sys, "argv", [str(tmp_path / "cli.py")]) - monkeypatch.setattr(sys, "executable", str(tmp_path / "bin" / "python")) assert guard_module._agent_scan_bin() is None From 24165c90a704d73f67ec072560a4021372304f43 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 11:09:45 +0200 Subject: [PATCH 41/58] fix: make session discovery opt-in --- docs/cli-reference.md | 2 +- src/agent_scan/guard.py | 31 ++-- .../hooks/snyk-agent-guard-discover.ps1 | 4 +- .../hooks/snyk-agent-guard-discover.sh | 7 +- tests/conftest.py | 13 ++ tests/e2e/test_guard_install.py | 12 +- tests/unit/test_guard.py | 156 ++++++++++++++---- 7 files changed, 165 insertions(+), 60 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index db0fa641..83f1c640 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -400,7 +400,7 @@ snyk-agent-scan guard uninstall {claude,cursor,codex,all} [OPTIONS] | `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_BIN` | Agent Scan executable used by the session-start discovery trampoline | +| `AGENT_SCAN_BIN` | Optional Agent Scan executable. The session-start discovery hook is installed only when this is set, and its trampoline invokes only this path without a `PATH` lookup. | ## Environment variables diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 0dadbf23..ca2b469d 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -489,6 +489,13 @@ def _install_hooks( push_key_changed = bool(old_push_key) and old_push_key != push_key is_codex_requirements = _is_codex_requirements_toml(config_path) + agent_scan_bin = _agent_scan_bin() + install_discovery = not is_codex_requirements and agent_scan_bin is not None + if not is_codex_requirements and agent_scan_bin is None: + rich.print( + "[yellow]Warning:[/yellow] AGENT_SCAN_BIN 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() ( @@ -499,7 +506,7 @@ def _install_hooks( new_checksum, discover_current_checksum, discover_new_checksum, - ) = _copy_hook_script(config_path, include_discover=not is_codex_requirements) + ) = _copy_hook_script(config_path, include_discover=install_discovery) command = _build_hook_command( push_key, url, @@ -509,11 +516,13 @@ def _install_hooks( machine_id=machine_id, ) discover_command = None - if not is_codex_requirements: + if install_discovery: + assert agent_scan_bin is not None discover_command = _build_discover_hook_command( push_key, url, discover_script_path, + agent_scan_bin=agent_scan_bin, tenant_id=tenant_id, machine_id=machine_id, hook_client=hook_client, @@ -551,6 +560,9 @@ def _install_hooks( 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]") @@ -1601,8 +1613,8 @@ def _build_hook_command( def _agent_scan_bin() -> str | None: - """The binary the session-start hook should invoke, or None to let it use PATH.""" - return os.environ.get("AGENT_SCAN_BIN") + """The configured binary the session-start hook should invoke, if any.""" + return os.environ.get("AGENT_SCAN_BIN", "").strip() or None def _build_discover_hook_command( @@ -1611,6 +1623,7 @@ def _build_discover_hook_command( script_path: Path, hook_client: str, *, + agent_scan_bin: str, tenant_id: str = "", machine_id: str = "", ) -> str: @@ -1620,6 +1633,7 @@ def _build_discover_hook_command( url, script_path, hook_client, + agent_scan_bin=agent_scan_bin, tenant_id=tenant_id, machine_id=machine_id, ) @@ -1629,9 +1643,7 @@ def _build_discover_hook_command( ] if machine_id: parts.append(f"MACHINE_ID={_shell_quote(machine_id)}") - agent_scan_bin = _agent_scan_bin() - if agent_scan_bin is not None: - parts.append(f"AGENT_SCAN_BIN={_shell_quote(agent_scan_bin)}") + parts.append(f"AGENT_SCAN_BIN={_shell_quote(agent_scan_bin)}") parts.append(f"bash {_shell_quote(script_path.as_posix())}") parts.append(f"--client {_shell_quote(hook_client)}") parts.append("--scope servers") @@ -1644,6 +1656,7 @@ def _build_discover_hook_command_powershell( script_path: Path, hook_client: str, *, + agent_scan_bin: str, tenant_id: str = "", machine_id: str = "", ) -> str: @@ -1653,9 +1666,7 @@ def _build_discover_hook_command_powershell( ) if machine_id: command += f" -MachineId {_ps_quote(machine_id)}" - agent_scan_bin = _agent_scan_bin() - if agent_scan_bin is not None: - command += f" -AgentScanBin {_ps_quote(agent_scan_bin)}" + command += f" -AgentScanBin {_ps_quote(agent_scan_bin)}" command += " -Scope servers" return command diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 index a3f133f8..0b19efbc 100644 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -33,8 +33,8 @@ if (-not $MachineId) { $MachineId = $env:MACHINE_ID } if (-not $MachineId) { exit 0 } $env:MACHINE_ID = $MachineId -$bin = if ($AgentScanBin) { $AgentScanBin } elseif ($env:AGENT_SCAN_BIN) { $env:AGENT_SCAN_BIN } else { "snyk-agent-scan" } -if (-not (Get-Command $bin -ErrorAction SilentlyContinue)) { $bin = "snyk-agent-scan" } +$bin = if ($AgentScanBin) { $AgentScanBin } elseif ($env:AGENT_SCAN_BIN) { $env:AGENT_SCAN_BIN } else { $null } +if (-not $bin) { exit 0 } $arguments = @("guard", "discover", "--client", $Client, "--scope", $Scope) diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh index ab0ffb34..8a039dae 100755 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.sh +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -1,9 +1,6 @@ #!/usr/bin/env bash set -euo pipefail [[ -n "${MACHINE_ID:-}" ]] || exit 0 -bin="${AGENT_SCAN_BIN:-snyk-agent-scan}" -if ! command -v "$bin" >/dev/null 2>&1; then - bin="snyk-agent-scan" -fi -"$bin" guard discover "$@" >/dev/null 2>&1 || true +[[ -n "${AGENT_SCAN_BIN:-}" ]] || exit 0 +"$AGENT_SCAN_BIN" guard discover "$@" >/dev/null 2>&1 || true exit 0 diff --git a/tests/conftest.py b/tests/conftest.py index f7ccb1ff..75104d81 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_bin(monkeypatch): + """Keep session-start discovery opt-in unless a test enables it explicitly.""" + monkeypatch.delenv("AGENT_SCAN_BIN", 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_bin() -> 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 5f6a349c..cd852a18 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -53,7 +53,7 @@ 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): + def test_guard_install_claude(self, agent_scan_cmd, agent_scan_bin, tmp_path, fake_hook_server): config_file = tmp_path / "settings.json" result = subprocess.run( [ @@ -71,7 +71,7 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): capture_output=True, text=True, timeout=60, - env={**os.environ, "PUSH_KEY": "test-pk-e2e"}, + env={**os.environ, "PUSH_KEY": "test-pk-e2e", "AGENT_SCAN_BIN": str(agent_scan_bin)}, ) assert result.returncode == 0, f"guard install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" @@ -125,7 +125,7 @@ def test_guard_install_claude(self, agent_scan_cmd, tmp_path, fake_hook_server): 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_bin, tmp_path, fake_hook_server): config_file = tmp_path / "hooks.json" result = subprocess.run( [ @@ -143,7 +143,7 @@ def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): capture_output=True, text=True, timeout=60, - env={**os.environ, "PUSH_KEY": "test-pk-e2e"}, + env={**os.environ, "PUSH_KEY": "test-pk-e2e", "AGENT_SCAN_BIN": str(agent_scan_bin)}, ) assert result.returncode == 0, f"guard install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" @@ -185,7 +185,7 @@ def test_guard_install_cursor(self, agent_scan_cmd, tmp_path, fake_hook_server): 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, tmp_path, fake_hook_server): + def test_guard_install_codex(self, agent_scan_cmd, agent_scan_bin, tmp_path, fake_hook_server): config_file = tmp_path / "hooks.json" result = subprocess.run( [ @@ -203,7 +203,7 @@ def test_guard_install_codex(self, agent_scan_cmd, tmp_path, fake_hook_server): capture_output=True, text=True, timeout=60, - env={**os.environ, "PUSH_KEY": "test-pk-e2e"}, + env={**os.environ, "PUSH_KEY": "test-pk-e2e", "AGENT_SCAN_BIN": str(agent_scan_bin)}, ) assert result.returncode == 0, f"guard install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 563f5b37..bf8392e9 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -307,6 +307,12 @@ def test_returns_none_when_environment_unset(self, monkeypatch): assert guard_module._agent_scan_bin() is None + @pytest.mark.parametrize("value", ["", " "]) + def test_returns_none_when_environment_value_is_blank(self, monkeypatch, value): + monkeypatch.setenv("AGENT_SCAN_BIN", value) + + assert guard_module._agent_scan_bin() is None + class TestBuildDiscoverHookCommand: @pytest.mark.parametrize( @@ -320,15 +326,13 @@ def test_client_payload_fields_match_hook_schemas(self, client, expected_field): @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): - with ( - patch(f"{_G}.IS_WINDOWS", False), - patch(f"{_G}._agent_scan_bin", return_value="/opt/Snyk's bin/snyk-agent-scan"), - ): + 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_bin="/opt/Snyk's bin/snyk-agent-scan", tenant_id="tenant", machine_id="machine", ) @@ -341,32 +345,15 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): assert command.endswith(f"bash '/x/snyk-agent-guard-discover.sh' --client '{client}' --scope servers") assert _is_agent_scan_command(command) - def test_omits_agent_scan_binary_when_unresolved(self): - with ( - patch(f"{_G}.IS_WINDOWS", False), - patch(f"{_G}._agent_scan_bin", return_value=None), - ): - command = guard_module._build_discover_hook_command( - "pk", - "https://api.snyk.io", - Path("/x/snyk-agent-guard-discover.sh"), - "cursor", - ) - - assert "AGENT_SCAN_BIN" not in command - assert command.endswith("--client 'cursor' --scope servers") - @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), - patch(f"{_G}._agent_scan_bin", return_value=r"C:\Program Files\Snyk\snyk-agent-scan.exe"), - ): + 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_bin=r"C:\Program Files\Snyk\snyk-agent-scan.exe", tenant_id="ignored", machine_id="machine's-id", ) @@ -378,15 +365,13 @@ def test_builds_powershell_command_for_each_client(self, client): ) def test_powershell_escapes_single_quotes_in_paths(self): - with ( - patch(f"{_G}.IS_WINDOWS", True), - patch(f"{_G}._agent_scan_bin", return_value=r"C:\Users\O'Brien\snyk-agent-scan.exe"), - ): + 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_bin=r"C:\Users\O'Brien\snyk-agent-scan.exe", ) assert r"-File 'C:\Users\O''Brien\discover.ps1'" in command @@ -584,11 +569,8 @@ def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_pat assert discover_script.read_text() == ( "#!/usr/bin/env bash\nset -euo pipefail\n" '[[ -n "${MACHINE_ID:-}" ]] || exit 0\n' - 'bin="${AGENT_SCAN_BIN:-snyk-agent-scan}"\n' - 'if ! command -v "$bin" >/dev/null 2>&1; then\n' - ' bin="snyk-agent-scan"\n' - "fi\n" - '"$bin" guard discover "$@" >/dev/null 2>&1 || true\n' + '[[ -n "${AGENT_SCAN_BIN:-}" ]] || exit 0\n' + '"$AGENT_SCAN_BIN" guard discover "$@" >/dev/null 2>&1 || true\n' "exit 0\n" ) assert os.access(discover_script, os.X_OK) @@ -609,7 +591,7 @@ def test_copy_reports_discovery_script_checksums(self, tmp_path): assert scripts.discover_current_checksum == hashlib.sha256(b"stale discovery script\n").hexdigest() assert scripts.discover_new_checksum == hashlib.sha256(discover_script.read_bytes()).hexdigest() - def test_stale_absolute_binary_falls_back_to_path(self, tmp_path): + def test_stale_absolute_binary_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() @@ -635,7 +617,34 @@ def test_stale_absolute_binary_falls_back_to_path(self, tmp_path): ) assert result.returncode == 0 - assert marker.read_text().strip() == "guard discover --client claude-code" + assert not marker.exists() + + def test_unset_binary_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_BIN", 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_nonzero_discovery_exit_is_swallowed(self, tmp_path): script = Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.sh" @@ -1621,6 +1630,7 @@ def test_guard_install_does_not_write_orphan_discovery_script(self, tmp_path): 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_BIN": "/usr/local/bin/snyk-agent-scan"}), ): _install_hooks( "codex", @@ -1950,7 +1960,7 @@ def test_missing_machine_id_exits_zero_without_invoking_binary(self, tmp_path): assert result.returncode == 0 assert not marker.exists() - def test_stale_absolute_binary_falls_back_to_path(self, tmp_path): + def test_stale_absolute_binary_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() @@ -1969,7 +1979,26 @@ def test_stale_absolute_binary_falls_back_to_path(self, tmp_path): ) assert result.returncode == 0, result.stderr - assert marker.exists() + assert not marker.exists() + + def test_unset_binary_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_BIN", 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") @@ -2411,6 +2440,7 @@ def ctx(self): f"{_G}._copy_hook_script", (dest, True, False, _CURRENT_CHECKSUM, _NEW_CHECKSUM, None, None), ), + "agent_scan_bin": (f"{_G}._agent_scan_bin", "/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)), @@ -2486,6 +2516,7 @@ def test_claude_builds_and_prepares_async_discovery_hook(self, ctx, tmp_path): ctx["build_discover"].assert_called_once() assert ctx["build_discover"].call_args.kwargs == { + "agent_scan_bin": "/usr/local/bin/snyk-agent-scan", "tenant_id": "tid-1", "machine_id": "machine-42", "hook_client": "claude-code", @@ -2522,6 +2553,59 @@ def test_codex_managed_does_not_build_discovery_hook(self, ctx, tmp_path): ctx["build_discover"].assert_not_called() ctx["copy"].assert_called_once_with(config, include_discover=False) + def test_unset_agent_scan_bin_skips_discovery_without_aborting(self, ctx, tmp_path): + ctx["agent_scan_bin"].return_value = None + + config = self._call(tmp_path, client="claude") + + ctx["copy"].assert_called_once_with(config, include_discover=False) + 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_bin_warns_once_per_client(self, ctx, tmp_path, client, hook_client): + ctx["agent_scan_bin"].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_BIN is not set" in message] + assert warnings == [ + "[yellow]Warning:[/yellow] AGENT_SCAN_BIN is not set; " + "the session-start discovery hook will not be installed" + ] + + def test_unset_agent_scan_bin_removes_stale_script_after_config_write(self, ctx, tmp_path): + ctx["agent_scan_bin"].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_claude"].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_bin_keeps_stale_script_when_test_event_fails(self, ctx, tmp_path): + ctx["agent_scan_bin"].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", From 33499ab454738e9d283449604ed4da1eaa97b344 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 12:44:45 +0200 Subject: [PATCH 42/58] feat: add discovery hook for managed Codex --- src/agent_scan/guard.py | 109 ++++++++++++++------ tests/unit/test_guard.py | 212 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 283 insertions(+), 38 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index ca2b469d..d23e1cd3 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -423,7 +423,11 @@ def _prepare_client_config( ) 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, discover_command=discover_command @@ -488,10 +492,9 @@ def _install_hooks( 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 - is_codex_requirements = _is_codex_requirements_toml(config_path) agent_scan_bin = _agent_scan_bin() - install_discovery = not is_codex_requirements and agent_scan_bin is not None - if not is_codex_requirements and agent_scan_bin is None: + install_discovery = agent_scan_bin is not None + if agent_scan_bin is None: rich.print( "[yellow]Warning:[/yellow] AGENT_SCAN_BIN is not set; " "the session-start discovery hook will not be installed" @@ -745,7 +748,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 = [ @@ -764,35 +772,67 @@ def _render_codex_requirements_toml(command: str, config_path: Path) -> str: lines.append('type = "command"') lines.append(f'command = "{escaped}"') lines.append("") + if discover_command: + escaped_discover = discover_command.replace("\\", "\\\\").replace('"', '\\"') + lines.append("[[hooks.SessionStart]]") + lines.append("[[hooks.SessionStart.hooks]]") + lines.append('type = "command"') + lines.append(f'command = "{escaped_discover}"') + lines.append("async = true") + lines.append("") return "\n".join(lines).rstrip("\n") + "\n" -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). """ - 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 @@ -810,14 +850,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*$') @@ -830,19 +874,24 @@ def _parse_codex_requirements_toml(text: str) -> tuple[list[str], str | None]: 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: + 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: @@ -850,7 +899,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 diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index bf8392e9..66b00109 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -1499,6 +1499,11 @@ 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_BIN='/usr/local/bin/snyk-agent-scan' " + "bash '/home/u/.codex/hooks/snyk-agent-guard-discover.sh' --client codex --scope servers" +) class TestUninstallCodex: @@ -1582,8 +1587,12 @@ def _import_managed_helpers(self): _uninstall_codex_managed, ) - def _install(command, path, _script=None): - content, _ = _prepare_codex_managed_config(command, 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 ( @@ -1606,6 +1615,83 @@ def test_render_contains_features_and_all_events(self, tmp_path): 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 = "{managed_dir}"', + f"windows_managed_dir = '{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_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) + + assert events == CODEX_HOOK_EVENTS + assert guard_command == CODEX_AGENT_SCAN_CMD + assert discover_command == CODEX_DISCOVER_CMD + def test_install_writes_toml(self, tmp_path): install, _, _, _ = self._import_managed_helpers() path = tmp_path / "requirements.toml" @@ -1623,7 +1709,7 @@ 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_does_not_write_orphan_discovery_script(self, tmp_path): + def test_guard_install_writes_discovery_script_and_toml_entry(self, tmp_path): path = tmp_path / "requirements.toml" with ( @@ -1647,7 +1733,41 @@ def test_guard_install_does_not_write_orphan_discovery_script(self, tmp_path): ) assert (tmp_path / "hooks" / "snyk-agent-guard.sh").exists() - assert not (tmp_path / "hooks" / "snyk-agent-guard-discover.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_BIN='/usr/local/bin/snyk-agent-scan'" in text + + def test_guard_install_without_agent_scan_bin_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_bin", 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_BIN 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() @@ -1661,6 +1781,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" @@ -1690,9 +1868,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" @@ -2545,13 +2740,14 @@ def test_codex_json_builds_discovery_hook(self, ctx, tmp_path): 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_does_not_build_discovery_hook(self, ctx, tmp_path): + 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_not_called() - ctx["copy"].assert_called_once_with(config, include_discover=False) + ctx["build_discover"].assert_called_once() + ctx["copy"].assert_called_once_with(config, include_discover=True) + assert ctx["prep_codex_managed"].call_args.kwargs["discover_command"] == "discover-cmd" def test_unset_agent_scan_bin_skips_discovery_without_aborting(self, ctx, tmp_path): ctx["agent_scan_bin"].return_value = None From 2dbddf9a32b0178ff1266ff1e781b683ba7d8d2f Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 14:07:32 +0200 Subject: [PATCH 43/58] refactor: make hook script copy generic --- src/agent_scan/guard.py | 129 ++++++++------------------ tests/unit/test_guard.py | 196 ++++++++++++++++++++++++--------------- 2 files changed, 164 insertions(+), 161 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index d23e1cd3..b648a236 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -469,9 +469,18 @@ 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 config_path.parent / "hooks" / name + return _hooks_dir(config_path) / name def _install_hooks( @@ -501,15 +510,12 @@ def _install_hooks( ) discover_script_path = _discover_script_path(config_path) discover_script_existed = discover_script_path.exists() - ( - dest_path, - script_existed, - script_updated, - current_checksum, - new_checksum, - discover_current_checksum, - discover_new_checksum, - ) = _copy_hook_script(config_path, include_discover=install_discovery) + + 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, @@ -537,7 +543,7 @@ def _install_hooks( discover_command=discover_command, ) - first_install = not script_existed + 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( @@ -549,13 +555,13 @@ 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, - discover_current_checksum=discover_current_checksum, - discover_new_checksum=discover_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) @@ -1762,96 +1768,43 @@ def _compact_events(events: list[str]) -> str: return f"({', '.join(events[:show])} + {len(events) - show} more)" -class _HookScripts(NamedTuple): +class _CopiedScript(NamedTuple): path: Path existed: bool updated: bool current_checksum: str | None new_checksum: str - discover_current_checksum: str | None = None - discover_new_checksum: str | None = None -def _copy_hook_script(config_path: Path, *, include_discover: bool = True) -> _HookScripts: - """Copy bundled hook scripts to a hooks/ dir next to the config file. +def _copy_hook_script(dest: Path) -> _CopiedScript: + """Copy the bundled hook script named ``dest.name`` to *dest*. - Checksums describe both the forwarding script and, when requested, the - session-start discovery trampoline. + Handles both the forwarding hook and the session-start discovery trampoline; + the bundled resource and the destination share a basename. """ - 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() - 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() - discover_current_checksum: str | None = None - discover_new_checksum: str | None = None - discover_updated = False - if include_discover: - discover_dest = _discover_script_path(config_path) - discover_source = hook_pkg.joinpath(discover_dest.name) - discover_content = discover_source.read_bytes() - discover_new_checksum = hashlib.sha256(discover_content).hexdigest() - discover_existing_content: bytes | None = None - if discover_dest.exists(): - discover_existing_content = discover_dest.read_bytes() - discover_current_checksum = hashlib.sha256(discover_existing_content).hexdigest() - if discover_existing_content != discover_content: - discover_dest.write_bytes(discover_content) - rich.print(f"[green]\u2713[/green] Copied hook script to [dim]{discover_dest}[/dim]") - discover_updated = True - if not IS_WINDOWS: - discover_dest.chmod(discover_dest.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) - - if existed and current_checksum == new_checksum: - return _HookScripts( - dest, - existed, - discover_updated, - current_checksum, - new_checksum, - discover_current_checksum, - discover_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() - 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 _HookScripts( - dest, - existed, - True, - current_checksum, - new_checksum, - discover_current_checksum, - discover_new_checksum, - ) + 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) + + 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_names = ( - ["snyk-agent-guard.ps1", "snyk-agent-guard-discover.ps1"] - if IS_WINDOWS - else [ - "snyk-agent-guard.sh", - "snyk-agent-guard-discover.sh", - ] - ) - for script_name in script_names: - dest = dest_dir / script_name + 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]") diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 66b00109..d3f03ebb 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -13,6 +13,7 @@ from pathlib import Path, PurePosixPath from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import call as mock_call import pytest @@ -563,8 +564,8 @@ class TestDiscoveryHookScriptFiles: def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_path): config = tmp_path / "settings.json" - main_script, *_ = guard_module._copy_hook_script(config) - discover_script = main_script.with_name("snyk-agent-guard-discover.sh") + 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" @@ -579,17 +580,17 @@ def test_copy_reports_discovery_script_checksums(self, tmp_path): import hashlib config = tmp_path / "settings.json" - scripts = guard_module._copy_hook_script(config) - discover_script = scripts.path.with_name("snyk-agent-guard-discover.sh") + discover_script = guard_module._discover_script_path(config) + script = guard_module._copy_hook_script(discover_script) - assert scripts.discover_current_checksum is None - assert scripts.discover_new_checksum == hashlib.sha256(discover_script.read_bytes()).hexdigest() + 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") - scripts = guard_module._copy_hook_script(config) + script = guard_module._copy_hook_script(discover_script) - assert scripts.discover_current_checksum == hashlib.sha256(b"stale discovery script\n").hexdigest() - assert scripts.discover_new_checksum == hashlib.sha256(discover_script.read_bytes()).hexdigest() + 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_binary_does_not_fall_back_to_path(self, tmp_path): script = Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.sh" @@ -686,36 +687,46 @@ def test_missing_machine_id_exits_zero_without_invoking_binary(self, tmp_path): def test_copy_restores_missing_discovery_script_when_forwarder_is_current(self, tmp_path): config = tmp_path / "settings.json" - main_script, *_ = guard_module._copy_hook_script(config) - discover_script = main_script.with_name("snyk-agent-guard-discover.sh") + 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(config) + 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._copy_hook_script(config) - main_script.with_name("snyk-agent-guard-discover.sh").unlink() + 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() - _, _, was_updated, *_ = guard_module._copy_hook_script(config) + copied_discovery = guard_module._copy_hook_script(discover_script) - assert was_updated is True + 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" - guard_module._copy_hook_script(config) + 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) - _, _, was_updated, *_ = guard_module._copy_hook_script(config) + copied_main = guard_module._copy_hook_script(main_script) + copied_discovery = guard_module._copy_hook_script(discover_script) - assert was_updated is False + 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._copy_hook_script(config) - discover_script = main_script.with_name("snyk-agent-guard-discover.sh") - discover_script.write_text("discovery") + 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) @@ -733,8 +744,10 @@ def test_full_claude_install_shape_then_uninstall_removes_entries_and_scripts(se discover_command=discover_command, ) _write_claude_config(settings, config, preserved) - main_script, *_ = guard_module._copy_hook_script(config) - discover_script = main_script.with_name("snyk-agent-guard-discover.sh") + 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)) @@ -748,9 +761,9 @@ def test_copy_writes_discovery_script_next_to_forwarder(self, tmp_path): config = tmp_path / "settings.json" with patch(f"{_G}.IS_WINDOWS", True): - main_script, *_ = guard_module._copy_hook_script(config) + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(discover_script) - discover_script = main_script.with_name("snyk-agent-guard-discover.ps1") assert ( discover_script.read_bytes() == (Path(guard_module.__file__).parent / "hooks" / "snyk-agent-guard-discover.ps1").read_bytes() @@ -759,20 +772,22 @@ def test_copy_writes_discovery_script_next_to_forwarder(self, tmp_path): def test_copy_restores_missing_script_and_reports_update(self, tmp_path): config = tmp_path / "settings.json" with patch(f"{_G}.IS_WINDOWS", True): - main_script, *_ = guard_module._copy_hook_script(config) - discover_script = main_script.with_name("snyk-agent-guard-discover.ps1") + discover_script = guard_module._discover_script_path(config) + guard_module._copy_hook_script(discover_script) discover_script.unlink() - _, _, was_updated, *_ = guard_module._copy_hook_script(config) + copied_discovery = guard_module._copy_hook_script(discover_script) assert discover_script.exists() - assert was_updated is True + 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._copy_hook_script(config) - discover_script = main_script.with_name("snyk-agent-guard-discover.ps1") + 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) @@ -2617,6 +2632,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: @@ -2631,10 +2648,7 @@ def ctx(self): """ dest = MagicMock(name="dest_path") targets = { - "copy": ( - f"{_G}._copy_hook_script", - (dest, True, False, _CURRENT_CHECKSUM, _NEW_CHECKSUM, None, None), - ), + "copy": (f"{_G}._copy_hook_script", _NO_RETURN_VALUE), "agent_scan_bin": (f"{_G}._agent_scan_bin", "/usr/local/bin/snyk-agent-scan"), "build": (f"{_G}._build_hook_command", "test-cmd"), "build_discover": (f"{_G}._build_discover_hook_command", "discover-cmd"), @@ -2653,11 +2667,32 @@ def ctx(self): "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() @@ -2693,12 +2728,15 @@ 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_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, include_discover=True) + 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") @@ -2746,7 +2784,10 @@ def test_codex_managed_builds_discovery_hook(self, ctx, tmp_path): config = self._call(tmp_path, client="codex", hook_client="codex") ctx["build_discover"].assert_called_once() - ctx["copy"].assert_called_once_with(config, include_discover=True) + 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_unset_agent_scan_bin_skips_discovery_without_aborting(self, ctx, tmp_path): @@ -2754,7 +2795,7 @@ def test_unset_agent_scan_bin_skips_discovery_without_aborting(self, ctx, tmp_pa config = self._call(tmp_path, client="claude") - ctx["copy"].assert_called_once_with(config, include_discover=False) + 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 @@ -2884,7 +2925,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, None, None) + 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 @@ -2904,7 +2945,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, None, None) + 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", @@ -2917,14 +2958,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=None, - discover_new_checksum=None, + 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, None, None) + 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", @@ -2937,8 +2978,8 @@ 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=None, - discover_new_checksum=None, + discover_current_checksum=_DISCOVER_CURRENT_CHECKSUM, + discover_new_checksum=_DISCOVER_NEW_CHECKSUM, machine_id="", ) @@ -2956,14 +2997,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=None, - discover_new_checksum=None, + 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, None, None) + 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", @@ -2976,14 +3017,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=None, - discover_new_checksum=None, + 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, None, None) + 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", @@ -2996,8 +3037,8 @@ 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=None, - discover_new_checksum=None, + discover_current_checksum=_DISCOVER_CURRENT_CHECKSUM, + discover_new_checksum=_DISCOVER_NEW_CHECKSUM, machine_id="", ) @@ -3007,7 +3048,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, None, None) + 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 @@ -3021,12 +3062,10 @@ def test_test_event_checksums_existing_install(self, ctx, tmp_path): assert kwargs["new_checksum"] == _NEW_CHECKSUM def test_test_event_receives_discovery_script_checksums(self, ctx, tmp_path): - ctx["copy"].return_value = ( - ctx["dest"], + ctx["discover_script"] = guard_module._CopiedScript( + MagicMock(name="discover_dest_path"), True, False, - _CURRENT_CHECKSUM, - _NEW_CHECKSUM, "discover-current", "discover-new", ) @@ -3053,14 +3092,14 @@ 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, None, None) + 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, None, None) + 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) @@ -3072,11 +3111,18 @@ def test_test_event_failure_cleans_new_discovery_script(self, ctx, tmp_path): ) discover_script = tmp_path / "hooks" / discover_script_name - def copy_scripts(_config_path, *, include_discover): - assert include_discover is True + 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 ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None + return guard_module._CopiedScript( + discover_script, + False, + True, + None, + _DISCOVER_NEW_CHECKSUM, + ) ctx["copy"].side_effect = copy_scripts ctx["test_event"].return_value = False @@ -3093,7 +3139,7 @@ def test_test_event_failure_keeps_existing_discovery_script(self, ctx, tmp_path) discover_script = tmp_path / "hooks" / discover_script_name discover_script.parent.mkdir(parents=True) discover_script.write_text("existing\n") - ctx["copy"].return_value = (ctx["dest"], False, True, None, _NEW_CHECKSUM, None, None) + ctx["main_script"] = guard_module._CopiedScript(ctx["dest"], False, True, None, _NEW_CHECKSUM) ctx["test_event"].return_value = False with pytest.raises(SystemExit): @@ -3102,14 +3148,12 @@ def test_test_event_failure_keeps_existing_discovery_script(self, ctx, 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["main_script"] = guard_module._CopiedScript( ctx["dest"], True, False, _CURRENT_CHECKSUM, _NEW_CHECKSUM, - None, - None, ) ctx["test_event"].return_value = False with pytest.raises(SystemExit): @@ -3168,19 +3212,25 @@ def test_status_installed_when_config_written(self, ctx, tmp_path): 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["main_script"] = guard_module._CopiedScript( ctx["dest"], True, True, _CURRENT_CHECKSUM, _NEW_CHECKSUM, - None, - None, ) ctx["write_claude"].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_claude"].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 self._call(tmp_path, minted=True, config_exists=True) From f4bbec99838606ab6f7a00337e2d0e4533060f7b Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 15:04:45 +0200 Subject: [PATCH 44/58] refactor: unify hook command builders behind one invocation spec The four command builders were a platform x variant cross-product, each re-implementing the same platform logic, and _invoke_hook_script described the same contract a fifth time as an argv list plus env dict with no shared code. Adding a field meant four coordinated edits and a fifth in a subprocess path; tenant_id had already drifted, accepted by all four builders but emitted by one. A single _HookInvocation now holds the raw values and three renderers turn it into a POSIX shell string, a PowerShell string, or an argv/env pair. Skipping empty optional fields is what lets one code path reproduce both variants, so the strings written into agent config files are unchanged: verified byte-identical across 5832 input combinations covering both platforms and embedded apostrophes. That matters because the emitted command is parsed back by _DETECTION_RE, _extract_env_from_cmd and the push-key redaction patterns, all of which depend on argument order and single-quoting. Two per-variant quirks stay encoded in the spec rather than normalised: the main forwarder leaves --client unquoted where discovery quotes it, and TENANT_ID is emitted only on the POSIX main path, where _parse_command_info reads it back as install-time metadata. _build_discover_hook_command_powershell is gone. Routing the Windows discovery path straight at the shared renderer left it unreferenced, and its output is reproduced exactly by the surviving path. _build_hook_command_powershell stays, since the live subprocess round-trip calls it directly on any platform. Tests pin the exact output of every builder per platform, so a reordering that _DETECTION_RE still happens to match can no longer slip through, and cover the discovery-shaped argv on both platforms. --- src/agent_scan/guard.py | 220 +++++++++++++++++++++++---------------- tests/unit/test_guard.py | 198 +++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 89 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index b648a236..c6dc1231 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -1287,29 +1287,15 @@ def _invoke_hook_script( if not machine_id.strip(): raise ValueError("machine ID is required") - if IS_WINDOWS: - cmd = [ - "powershell", - "-File", - str(script_path), - "-Client", - hook_client, - "-PushKey", - push_key, - "-RemoteUrl", - url, - "-MachineId", - machine_id, - ] - env = None - else: - cmd = ["bash", str(script_path), "--client", hook_client] - env = { - **os.environ, - "PUSH_KEY": push_key, - "REMOTE_HOOKS_BASE_URL": url, - "MACHINE_ID": machine_id, - } + 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( @@ -1636,76 +1622,126 @@ 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 = "", - machine_id: str = "", -) -> str: - if IS_WINDOWS: - return _build_hook_command_powershell( - push_key, - url, - script_path, - hook_client, - tenant_id=tenant_id, - machine_id=machine_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_bin: 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)}") - if machine_id: - parts.append(f"MACHINE_ID={_shell_quote(machine_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_bin: + parts.append(f"AGENT_SCAN_BIN={_shell_quote(invocation.agent_scan_bin)}") + 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 _agent_scan_bin() -> str | None: - """The configured binary the session-start hook should invoke, if any.""" - return os.environ.get("AGENT_SCAN_BIN", "").strip() or None +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_bin: + parts.extend(["-AgentScanBin", _ps_quote(invocation.agent_scan_bin)]) + if invocation.scope: + parts.extend(["-Scope", invocation.scope]) + return " ".join(parts) -def _build_discover_hook_command( +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_bin: + argv.extend(["-AgentScanBin", invocation.agent_scan_bin]) + 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_bin: + env["AGENT_SCAN_BIN"] = invocation.agent_scan_bin + 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, *, - agent_scan_bin: 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 _build_discover_hook_command_powershell( - push_key, - url, - script_path, - hook_client, - agent_scan_bin=agent_scan_bin, - tenant_id=tenant_id, - machine_id=machine_id, - ) - parts = [ - f"PUSH_KEY={_shell_quote(push_key)}", - f"REMOTE_HOOKS_BASE_URL={_shell_quote(url)}", - ] - if machine_id: - parts.append(f"MACHINE_ID={_shell_quote(machine_id)}") - parts.append(f"AGENT_SCAN_BIN={_shell_quote(agent_scan_bin)}") - parts.append(f"bash {_shell_quote(script_path.as_posix())}") - parts.append(f"--client {_shell_quote(hook_client)}") - parts.append("--scope servers") - return " ".join(parts) + return _render_powershell_command(invocation) + return _render_posix_command(invocation) -def _build_discover_hook_command_powershell( +def _agent_scan_bin() -> str | None: + """The configured binary the session-start hook should invoke, if any.""" + return os.environ.get("AGENT_SCAN_BIN", "").strip() or None + + +def _build_discover_hook_command( push_key: str, url: str, script_path: Path, @@ -1715,15 +1751,19 @@ def _build_discover_hook_command_powershell( tenant_id: str = "", machine_id: str = "", ) -> str: - command = ( - f"powershell -File {_ps_quote(str(script_path))} -Client {hook_client} " - f"-PushKey {_ps_quote(push_key)} -RemoteUrl {_ps_quote(url)}" + invocation = _HookInvocation( + script_path=script_path, + hook_client=hook_client, + push_key=push_key, + url=url, + machine_id=machine_id, + agent_scan_bin=agent_scan_bin, + scope="servers", + quote_client=True, ) - if machine_id: - command += f" -MachineId {_ps_quote(machine_id)}" - command += f" -AgentScanBin {_ps_quote(agent_scan_bin)}" - command += " -Scope servers" - return command + if IS_WINDOWS: + return _render_powershell_command(invocation) + return _render_posix_command(invocation) def _build_hook_command_powershell( @@ -1735,13 +1775,15 @@ def _build_hook_command_powershell( tenant_id: str = "", machine_id: str = "", ) -> str: - command = ( - f"powershell -File {_ps_quote(str(script_path))} -Client {hook_client} " - f"-PushKey {_ps_quote(push_key)} -RemoteUrl {_ps_quote(url)}" + return _render_powershell_command( + _HookInvocation( + script_path=script_path, + hook_client=hook_client, + push_key=push_key, + url=url, + machine_id=machine_id, + ) ) - if machine_id: - command += f" -MachineId {_ps_quote(machine_id)}" - return command def _shell_quote(s: str) -> str: diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index d3f03ebb..58db49d4 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -218,6 +218,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_BIN='/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' " + "-AgentScanBin '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_bin=( + 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): @@ -379,6 +445,138 @@ def test_powershell_escapes_single_quotes_in_paths(self): assert r"-AgentScanBin 'C:\Users\O''Brien\snyk-agent-scan.exe'" in command +class TestHookInvocationRenderers: + def test_render_argv_posix_returns_unquoted_argv_and_merged_environment(self): + invocation = guard_module._HookInvocation( + script_path=Path("/hooks/snyk-agent-guard.sh"), + 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) + + assert argv == ["bash", "/hooks/snyk-agent-guard.sh", "--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.""" + invocation = guard_module._HookInvocation( + script_path=Path("/hooks/snyk-agent-guard-discover.sh"), + hook_client="cursor", + push_key="pk", + url="https://api.snyk.io", + machine_id="machine", + tenant_id="tenant", + agent_scan_bin="/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", + "/hooks/snyk-agent-guard-discover.sh", + "--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_BIN": "/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_bin=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", + "-AgentScanBin", + 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'" From 901141ef6d4cf3696da5ea2e466e1e3af7b6e4aa Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 15:04:45 +0200 Subject: [PATCH 45/58] docs: drop the PATH-lookup claim for AGENT_SCAN_BIN An unset variable leaves the value out of the hook command so the trampolines fall back to PATH, and a bare executable name resolves through PATH even when the variable is set, so the trampoline does not bypass the lookup. --- docs/cli-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 83f1c640..fee98740 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -400,7 +400,7 @@ snyk-agent-scan guard uninstall {claude,cursor,codex,all} [OPTIONS] | `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_BIN` | Optional Agent Scan executable. The session-start discovery hook is installed only when this is set, and its trampoline invokes only this path without a `PATH` lookup. | +| `AGENT_SCAN_BIN` | Optional Agent Scan executable. The session-start discovery hook is installed only when this is set, and its trampoline invokes only this path. | ## Environment variables From 154e87f1b1dfd1885e7809a7655e886af063f680 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 15:46:40 +0200 Subject: [PATCH 46/58] refactor: generalize guard client operations --- src/agent_scan/guard.py | 257 +++++---------- tests/unit/test_guard.py | 661 +++++++++++++++++++++++++++++++++++---- 2 files changed, 675 insertions(+), 243 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index c6dc1231..50cbcb1e 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -32,7 +32,7 @@ from agent_scan.redact import redact_push_keys, redact_push_keys_in_data if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Iterable from agent_scan.models import ClientToInspect @@ -444,20 +444,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: @@ -632,12 +626,11 @@ def _prepare_claude_config( 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 @@ -678,15 +671,6 @@ def _prepare_cursor_config( 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, @@ -724,15 +708,6 @@ def _prepare_codex_config( 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" @@ -945,15 +920,15 @@ 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, + missing_label="settings.json" if client == "claude" else "hooks.json", + prune_empty_hooks=client != "cursor", + ) # Remove hook script _remove_hook_script(client, config_path) @@ -984,43 +959,22 @@ 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], + missing_label: str, + 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 {missing_label} 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 @@ -1029,34 +983,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)}") @@ -1066,36 +997,26 @@ 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 --machine-id [/dim]") @@ -1134,76 +1055,52 @@ 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", {}) - - 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) + return _detect_install(path, CLAUDE_HOOK_EVENTS, _grouped_hook_commands) def _detect_codex_install(path: Path = CODEX_HOOKS_PATH) -> dict | None: - if not path.exists(): - return None if _is_codex_requirements_toml(path): + if not path.exists(): + return None return _detect_codex_managed_install(path) - data = _read_json_or_empty(path) - hooks = data.get("hooks", {}) + return _detect_install(path, CODEX_HOOK_EVENTS, _grouped_hook_commands) - 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) - 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_cursor_install(path: Path = CURSOR_HOOKS_PATH) -> dict | None: + return _detect_install(path, CURSOR_HOOK_EVENTS, _flat_hook_commands) -def _detect_cursor_install(path: Path = CURSOR_HOOKS_PATH) -> dict | None: +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 data = _read_json_or_empty(path) hooks = data.get("hooks", {}) - events = [] + installed_events = [] found_cmd = None - for event in CURSOR_HOOK_EVENTS: + for event in 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 + for command in commands(entry): + if _is_agent_scan_command(command): + installed_events.append(event) + if found_cmd is None: + 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) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 58db49d4..7baa05b6 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -58,13 +58,9 @@ _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 @@ -105,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: @@ -123,6 +119,24 @@ 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, + missing_label="settings.json" if client == "claude" else "hooks.json", + 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 # =================================================================== @@ -632,7 +646,7 @@ def test_reprepare_is_idempotent(self, tmp_path): path, discover_command=self.discover_command, ) - _write_claude_config(settings, path, preserved) + _write_config(settings, path, preserved) _, diff, _ = _prepare_claude_config( AGENT_SCAN_CMD, @@ -673,7 +687,7 @@ def test_reprepare_is_idempotent(self, tmp_path): path, discover_command=self.discover_command, ) - _write_cursor_config(data, path, preserved) + _write_config(data, path, preserved) _, diff, _ = _prepare_cursor_config( AGENT_SCAN_CMD, @@ -690,9 +704,9 @@ def test_uninstall_removes_discovery_entry(self, tmp_path): path, discover_command=self.discover_command, ) - _write_cursor_config(data, path, preserved) + _write_config(data, path, preserved) - _uninstall_cursor(path) + _uninstall_test_client("cursor", path) assert not any( self.discover_command == entry.get("command") @@ -733,7 +747,7 @@ def test_reprepare_is_idempotent(self, tmp_path): path, discover_command=self.discover_command, ) - _write_codex_config(data, path, preserved) + _write_config(data, path, preserved) _, diff, _ = _prepare_codex_config( AGENT_SCAN_CMD, @@ -750,13 +764,77 @@ def test_uninstall_removes_discovery_entry(self, tmp_path): path, discover_command=self.discover_command, ) - _write_codex_config(data, path, preserved) + _write_config(data, path, preserved) - _uninstall_codex(path) + _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) + + assert "(2 other hook(s) preserved)" in capsys.readouterr().out + + 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): @@ -941,7 +1019,7 @@ def test_full_claude_install_shape_then_uninstall_removes_entries_and_scripts(se config, discover_command=discover_command, ) - _write_claude_config(settings, config, preserved) + _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) @@ -1015,12 +1093,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"]} @@ -1028,7 +1106,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 @@ -1047,7 +1125,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 @@ -1066,7 +1144,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 @@ -1084,7 +1162,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 @@ -1094,7 +1172,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() @@ -1105,7 +1183,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 @@ -1211,12 +1289,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} @@ -1224,7 +1302,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 @@ -1244,7 +1322,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 @@ -1254,7 +1332,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"] == {} @@ -1273,7 +1351,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 @@ -1283,7 +1361,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() @@ -1292,7 +1370,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"] == {} @@ -1540,7 +1618,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 @@ -1568,7 +1646,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"] == {} @@ -1604,6 +1682,17 @@ def test_detect_cursor_raises_on_unreadable(self, tmp_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) + def test_print_client_status_permission_denied(self, tmp_path, capsys): _print_client_status("Claude Code", tmp_path / "managed-settings.json", _PERMISSION_DENIED) output = capsys.readouterr().out @@ -1628,6 +1717,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 # =================================================================== @@ -1721,7 +1929,23 @@ def _get_script_path(name: str) -> Path: 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" @@ -1734,7 +1958,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 @@ -1744,16 +1968,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 @@ -1764,6 +2017,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}) @@ -1786,6 +2044,287 @@ 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"]) + 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 @@ -2857,9 +3396,7 @@ 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), @@ -3022,7 +3559,7 @@ def assert_stale_script_still_exists(*_args): assert discover_script.exists() return True - ctx["write_claude"].side_effect = assert_stale_script_still_exists + ctx["write"].side_effect = assert_stale_script_still_exists self._call(tmp_path, client="claude") @@ -3058,26 +3595,26 @@ def test_install_hooks_returns_none(self, ctx, tmp_path): 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): @@ -3086,7 +3623,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 @@ -3362,9 +3899,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() # --------------------------------------------------------------- @@ -3375,19 +3910,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 @@ -3398,14 +3933,14 @@ 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)) @@ -3417,25 +3952,25 @@ def test_status_installed_when_script_updated(self, ctx, tmp_path): _CURRENT_CHECKSUM, _NEW_CHECKSUM, ) - ctx["write_claude"].return_value = False + 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_claude"].return_value = False + 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)) @@ -5007,7 +5542,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 @@ -5032,7 +5567,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 @@ -5056,7 +5591,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 From 6f19050a63ab8d7f16718d9e0c37d5d5f6722848 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 16:22:38 +0200 Subject: [PATCH 47/58] feat: support agent scan discovery commands --- docs/cli-reference.md | 2 +- src/agent_scan/guard.py | 50 +++-- .../hooks/snyk-agent-guard-discover.ps1 | 13 +- .../hooks/snyk-agent-guard-discover.sh | 9 +- tests/conftest.py | 6 +- tests/e2e/test_guard_install.py | 12 +- tests/unit/test_guard.py | 211 +++++++++++++----- 7 files changed, 209 insertions(+), 94 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fee98740..147ac9b1 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -400,7 +400,7 @@ snyk-agent-scan guard uninstall {claude,cursor,codex,all} [OPTIONS] | `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_BIN` | Optional Agent Scan executable. The session-start discovery hook is installed only when this is set, and its trampoline invokes only this path. | +| `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 diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 50cbcb1e..d4b10cf1 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -201,6 +201,7 @@ def _run_install(args) -> None: if not tenant_id: tenant_id = (os.environ.get("TENANT_ID", "") or "").strip() managed: bool = getattr(args, "managed", False) + # for the release i'll create a new PR to add hostname as default for machine_id but i'll make it separate to be clear. machine_id needs to be mandatory if we want to reliably identify the servers machine_id = (getattr(args, "machine_id", None) or os.environ.get("MACHINE_ID", "") or "").strip() if not machine_id: rich.print("[bold red]Error:[/bold red] --machine-id is required (or set the MACHINE_ID environment variable).") @@ -302,8 +303,6 @@ def _run_install(args) -> None: raise if first_installed_client is not None: - # ``_servers_discovered_entries`` never emits skills, so requesting them here - # would walk every skills dir only to discard the result. _send_servers_discovered_event( push_key, url, @@ -495,11 +494,11 @@ def _install_hooks( 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 - agent_scan_bin = _agent_scan_bin() - install_discovery = agent_scan_bin is not None - if agent_scan_bin is None: + agent_scan_command = _agent_scan_command() + install_discovery = agent_scan_command is not None + if agent_scan_command is None: rich.print( - "[yellow]Warning:[/yellow] AGENT_SCAN_BIN is not set; " + "[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) @@ -520,12 +519,12 @@ def _install_hooks( ) discover_command = None if install_discovery: - assert agent_scan_bin is not None + assert agent_scan_command is not None discover_command = _build_discover_hook_command( push_key, url, discover_script_path, - agent_scan_bin=agent_scan_bin, + agent_scan_command=agent_scan_command, tenant_id=tenant_id, machine_id=machine_id, hook_client=hook_client, @@ -773,6 +772,13 @@ def _prepare_codex_managed_config( """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, @@ -1528,7 +1534,7 @@ class _HookInvocation(NamedTuple): url: str machine_id: str = "" tenant_id: str = "" - agent_scan_bin: str = "" + agent_scan_command: str = "" scope: str = "" quote_client: bool = False @@ -1542,8 +1548,8 @@ def _render_posix_command(invocation: _HookInvocation) -> str: 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_bin: - parts.append(f"AGENT_SCAN_BIN={_shell_quote(invocation.agent_scan_bin)}") + 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}") @@ -1566,8 +1572,8 @@ def _render_powershell_command(invocation: _HookInvocation) -> str: ] if invocation.machine_id: parts.extend(["-MachineId", _ps_quote(invocation.machine_id)]) - if invocation.agent_scan_bin: - parts.extend(["-AgentScanBin", _ps_quote(invocation.agent_scan_bin)]) + 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) @@ -1588,8 +1594,8 @@ def _render_argv(invocation: _HookInvocation) -> tuple[list[str], dict[str, str] ] if invocation.machine_id: argv.extend(["-MachineId", invocation.machine_id]) - if invocation.agent_scan_bin: - argv.extend(["-AgentScanBin", invocation.agent_scan_bin]) + if invocation.agent_scan_command: + argv.extend(["-AgentScanCommand", invocation.agent_scan_command]) if invocation.scope: argv.extend(["-Scope", invocation.scope]) return argv, None @@ -1603,8 +1609,8 @@ def _render_argv(invocation: _HookInvocation) -> tuple[list[str], dict[str, str] env["TENANT_ID"] = invocation.tenant_id if invocation.machine_id: env["MACHINE_ID"] = invocation.machine_id - if invocation.agent_scan_bin: - env["AGENT_SCAN_BIN"] = invocation.agent_scan_bin + 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]) @@ -1633,9 +1639,9 @@ def _build_hook_command( return _render_posix_command(invocation) -def _agent_scan_bin() -> str | None: - """The configured binary the session-start hook should invoke, if any.""" - return os.environ.get("AGENT_SCAN_BIN", "").strip() or None +def _agent_scan_command() -> str | None: + """The configured command the session-start hook should invoke, if any.""" + return os.environ.get("AGENT_SCAN_COMMAND", "").strip() or None def _build_discover_hook_command( @@ -1644,7 +1650,7 @@ def _build_discover_hook_command( script_path: Path, hook_client: str, *, - agent_scan_bin: str, + agent_scan_command: str, tenant_id: str = "", machine_id: str = "", ) -> str: @@ -1654,7 +1660,7 @@ def _build_discover_hook_command( push_key=push_key, url=url, machine_id=machine_id, - agent_scan_bin=agent_scan_bin, + agent_scan_command=agent_scan_command, scope="servers", quote_client=True, ) diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 index 0b19efbc..e1176250 100644 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -18,7 +18,7 @@ param( [string]$MachineId, [Parameter(Mandatory=$false)] - [string]$AgentScanBin, + [string]$AgentScanCommand, [Parameter(Mandatory=$false)] [ValidateSet("servers","skills","all")] @@ -33,8 +33,8 @@ if (-not $MachineId) { $MachineId = $env:MACHINE_ID } if (-not $MachineId) { exit 0 } $env:MACHINE_ID = $MachineId -$bin = if ($AgentScanBin) { $AgentScanBin } elseif ($env:AGENT_SCAN_BIN) { $env:AGENT_SCAN_BIN } else { $null } -if (-not $bin) { exit 0 } +$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) @@ -43,7 +43,12 @@ $arguments = @("guard", "discover", "--client", $Client, "--scope", $Scope) # 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 { - & $bin @arguments *> $null + if (Test-Path -LiteralPath $cmd -PathType Leaf) { + & $cmd @arguments *> $null + } else { + # TODO: ProdSec needs to review this shell-evaluation path before release. + Invoke-Expression "$cmd $($arguments -join ' ')" *> $null + } } catch { # Session-start discovery is best-effort telemetry. } diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh index 8a039dae..1c1a4a12 100755 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.sh +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail [[ -n "${MACHINE_ID:-}" ]] || exit 0 -[[ -n "${AGENT_SCAN_BIN:-}" ]] || exit 0 -"$AGENT_SCAN_BIN" guard discover "$@" >/dev/null 2>&1 || true +[[ -n "${AGENT_SCAN_COMMAND:-}" ]] || exit 0 +if [[ -x "$AGENT_SCAN_COMMAND" ]]; then + "$AGENT_SCAN_COMMAND" guard discover "$@" >/dev/null 2>&1 || true +else + # TODO: ProdSec needs to review this shell-evaluation path before release. + eval "$AGENT_SCAN_COMMAND guard discover \"\$@\"" >/dev/null 2>&1 || true +fi exit 0 diff --git a/tests/conftest.py b/tests/conftest.py index 75104d81..00209a22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,9 +51,9 @@ def _ensure_unicode_console(): @pytest.fixture(autouse=True) -def _clear_agent_scan_bin(monkeypatch): +def _clear_agent_scan_command(monkeypatch): """Keep session-start discovery opt-in unless a test enables it explicitly.""" - monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) + monkeypatch.delenv("AGENT_SCAN_COMMAND", raising=False) def _get_binary_path() -> Path: @@ -62,7 +62,7 @@ def _get_binary_path() -> Path: @pytest.fixture -def agent_scan_bin() -> Path: +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 diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index cd852a18..fc02e3b3 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -53,7 +53,7 @@ class TestGuardInstallE2E: """ @pytest.mark.parametrize("agent_scan_cmd", ["uv", "binary"], indirect=True) - def test_guard_install_claude(self, agent_scan_cmd, agent_scan_bin, tmp_path, fake_hook_server): + def test_guard_install_claude(self, agent_scan_cmd, agent_scan_command, tmp_path, fake_hook_server): config_file = tmp_path / "settings.json" result = subprocess.run( [ @@ -71,7 +71,7 @@ def test_guard_install_claude(self, agent_scan_cmd, agent_scan_bin, tmp_path, fa capture_output=True, text=True, timeout=60, - env={**os.environ, "PUSH_KEY": "test-pk-e2e", "AGENT_SCAN_BIN": str(agent_scan_bin)}, + 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}" @@ -125,7 +125,7 @@ def test_guard_install_claude(self, agent_scan_cmd, agent_scan_bin, tmp_path, fa 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, agent_scan_bin, 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( [ @@ -143,7 +143,7 @@ def test_guard_install_cursor(self, agent_scan_cmd, agent_scan_bin, tmp_path, fa capture_output=True, text=True, timeout=60, - env={**os.environ, "PUSH_KEY": "test-pk-e2e", "AGENT_SCAN_BIN": str(agent_scan_bin)}, + 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}" @@ -185,7 +185,7 @@ def test_guard_install_cursor(self, agent_scan_cmd, agent_scan_bin, tmp_path, fa 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_bin, tmp_path, fake_hook_server): + 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( [ @@ -203,7 +203,7 @@ def test_guard_install_codex(self, agent_scan_cmd, agent_scan_bin, tmp_path, fak capture_output=True, text=True, timeout=60, - env={**os.environ, "PUSH_KEY": "test-pk-e2e", "AGENT_SCAN_BIN": str(agent_scan_bin)}, + 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}" diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 7baa05b6..5e324f0d 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -251,7 +251,7 @@ def test_tenant_id(self): "discover", False, "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' MACHINE_ID='machine' " - "AGENT_SCAN_BIN='/usr/local/bin/snyk-agent-scan' bash '/x/snyk-agent-guard-discover.sh' " + "AGENT_SCAN_COMMAND='/usr/local/bin/snyk-agent-scan' bash '/x/snyk-agent-guard-discover.sh' " "--client 'claude-code' --scope servers", ), ( @@ -259,7 +259,7 @@ def test_tenant_id(self): True, "powershell -File 'C:\\hooks\\snyk-agent-guard-discover.ps1' -Client claude-code -PushKey 'pk' " "-RemoteUrl 'https://api.snyk.io' -MachineId 'machine' " - "-AgentScanBin 'C:\\Program Files\\Snyk\\snyk-agent-scan.exe' -Scope servers", + "-AgentScanCommand 'C:\\Program Files\\Snyk\\snyk-agent-scan.exe' -Scope servers", ), ], ) @@ -288,7 +288,7 @@ def test_build_hook_command_preserves_exact_output(variant, is_windows, expected "https://api.snyk.io", script_path, "claude-code", - agent_scan_bin=( + 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", @@ -377,22 +377,22 @@ def test_roundtrip_extract(self): assert _extract_env_from_cmd(cmd, "TENANT_ID") == "t-1" -class TestAgentScanBin: +class TestAgentScanCommand: def test_uses_environment_value(self, monkeypatch): - monkeypatch.setenv("AGENT_SCAN_BIN", "custom agent scan") + monkeypatch.setenv("AGENT_SCAN_COMMAND", "cd /repo; uv run -m src.agent_scan.cli") - assert guard_module._agent_scan_bin() == "custom agent scan" + assert guard_module._agent_scan_command() == "cd /repo; uv run -m src.agent_scan.cli" def test_returns_none_when_environment_unset(self, monkeypatch): - monkeypatch.delenv("AGENT_SCAN_BIN", raising=False) + monkeypatch.delenv("AGENT_SCAN_COMMAND", raising=False) - assert guard_module._agent_scan_bin() is None + assert guard_module._agent_scan_command() is None @pytest.mark.parametrize("value", ["", " "]) def test_returns_none_when_environment_value_is_blank(self, monkeypatch, value): - monkeypatch.setenv("AGENT_SCAN_BIN", value) + monkeypatch.setenv("AGENT_SCAN_COMMAND", value) - assert guard_module._agent_scan_bin() is None + assert guard_module._agent_scan_command() is None class TestBuildDiscoverHookCommand: @@ -406,14 +406,14 @@ def test_client_payload_fields_match_hook_schemas(self, client, expected_field): 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_binary(self, client): + 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_bin="/opt/Snyk's bin/snyk-agent-scan", + agent_scan_command="/opt/Snyk's bin/snyk-agent-scan", tenant_id="tenant", machine_id="machine", ) @@ -422,7 +422,7 @@ def test_builds_quoted_environment_prefix_with_agent_scan_binary(self, client): 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_BIN='/opt/Snyk'\"'\"'s bin/snyk-agent-scan'" 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) @@ -434,7 +434,7 @@ def test_builds_powershell_command_for_each_client(self, client): "https://api.snyk.io", Path(r"C:\hooks\snyk-agent-guard-discover.ps1"), client, - agent_scan_bin=r"C:\Program Files\Snyk\snyk-agent-scan.exe", + agent_scan_command=r"C:\Program Files\Snyk\snyk-agent-scan.exe", tenant_id="ignored", machine_id="machine's-id", ) @@ -442,7 +442,7 @@ def test_builds_powershell_command_for_each_client(self, client): 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"-AgentScanBin 'C:\Program Files\Snyk\snyk-agent-scan.exe' -Scope servers" + r"-AgentScanCommand 'C:\Program Files\Snyk\snyk-agent-scan.exe' -Scope servers" ) def test_powershell_escapes_single_quotes_in_paths(self): @@ -452,11 +452,31 @@ def test_powershell_escapes_single_quotes_in_paths(self): "https://api.snyk.io", Path(r"C:\Users\O'Brien\discover.ps1"), "claude-code", - agent_scan_bin=r"C:\Users\O'Brien\snyk-agent-scan.exe", + 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"-AgentScanBin 'C:\Users\O''Brien\snyk-agent-scan.exe'" 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: @@ -516,7 +536,7 @@ def test_render_argv_posix_carries_discovery_fields(self): url="https://api.snyk.io", machine_id="machine", tenant_id="tenant", - agent_scan_bin="/opt/Snyk's bin/snyk-agent-scan", + agent_scan_command="/opt/Snyk's bin/snyk-agent-scan", scope="servers", quote_client=True, ) @@ -538,7 +558,7 @@ def test_render_argv_posix_carries_discovery_fields(self): "REMOTE_HOOKS_BASE_URL": "https://api.snyk.io", "TENANT_ID": "tenant", "MACHINE_ID": "machine", - "AGENT_SCAN_BIN": "/opt/Snyk's bin/snyk-agent-scan", + "AGENT_SCAN_COMMAND": "/opt/Snyk's bin/snyk-agent-scan", } def test_render_argv_windows_carries_discovery_fields(self): @@ -549,7 +569,7 @@ def test_render_argv_windows_carries_discovery_fields(self): url="https://api.snyk.io", machine_id="machine", tenant_id="tenant", - agent_scan_bin=r"C:\Program Files\Snyk\snyk-agent-scan.exe", + agent_scan_command=r"C:\Program Files\Snyk\snyk-agent-scan.exe", scope="servers", ) @@ -568,7 +588,7 @@ def test_render_argv_windows_carries_discovery_fields(self): "https://api.snyk.io", "-MachineId", "machine", - "-AgentScanBin", + "-AgentScanCommand", r"C:\Program Files\Snyk\snyk-agent-scan.exe", "-Scope", "servers", @@ -846,8 +866,13 @@ def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_pat assert discover_script.read_text() == ( "#!/usr/bin/env bash\nset -euo pipefail\n" '[[ -n "${MACHINE_ID:-}" ]] || exit 0\n' - '[[ -n "${AGENT_SCAN_BIN:-}" ]] || exit 0\n' - '"$AGENT_SCAN_BIN" guard discover "$@" >/dev/null 2>&1 || true\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" + " # TODO: ProdSec needs to review this shell-evaluation path before release.\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) @@ -868,7 +893,7 @@ def test_copy_reports_discovery_script_checksums(self, tmp_path): 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_binary_does_not_fall_back_to_path(self, tmp_path): + 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() @@ -878,7 +903,7 @@ def test_stale_absolute_binary_does_not_fall_back_to_path(self, tmp_path): stub.chmod(0o755) env = { **os.environ, - "AGENT_SCAN_BIN": str(tmp_path / "deleted" / "snyk-agent-scan"), + "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', '')}", @@ -896,7 +921,7 @@ def test_stale_absolute_binary_does_not_fall_back_to_path(self, tmp_path): assert result.returncode == 0 assert not marker.exists() - def test_unset_binary_does_not_fall_back_to_path(self, tmp_path): + 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() @@ -909,7 +934,7 @@ def test_unset_binary_does_not_fall_back_to_path(self, tmp_path): "MACHINE_ID": "machine-42", "PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", } - env.pop("AGENT_SCAN_BIN", None) + env.pop("AGENT_SCAN_COMMAND", None) result = subprocess.run( ["bash", str(script), "--client", "claude-code"], @@ -923,6 +948,80 @@ def test_unset_binary_does_not_fall_back_to_path(self, tmp_path): 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" @@ -935,18 +1034,18 @@ def test_nonzero_discovery_exit_is_swallowed(self, tmp_path): text=True, capture_output=True, timeout=5, - env={**os.environ, "AGENT_SCAN_BIN": str(stub), "MACHINE_ID": "machine-42"}, + 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_binary(self, tmp_path): + 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_BIN": str(stub)} + env = {**os.environ, "AGENT_SCAN_COMMAND": str(stub)} env.pop("MACHINE_ID", None) result = subprocess.run( @@ -1922,7 +2021,7 @@ def _get_script_path(name: str) -> Path: ) CODEX_DISCOVER_CMD = ( "PUSH_KEY='pk-discover' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' " - "AGENT_SCAN_BIN='/usr/local/bin/snyk-agent-scan' " + "AGENT_SCAN_COMMAND='/usr/local/bin/snyk-agent-scan' " "bash '/home/u/.codex/hooks/snyk-agent-guard-discover.sh' --client codex --scope servers" ) @@ -2468,7 +2567,7 @@ def test_guard_install_writes_discovery_script_and_toml_entry(self, tmp_path): 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_BIN": "/usr/local/bin/snyk-agent-scan"}), + patch.dict(os.environ, {"AGENT_SCAN_COMMAND": "/usr/local/bin/snyk-agent-scan"}), ): _install_hooks( "codex", @@ -2489,9 +2588,9 @@ def test_guard_install_writes_discovery_script_and_toml_entry(self, tmp_path): text = path.read_text() assert text.count("[[hooks.SessionStart]]") == 2 assert "snyk-agent-guard-discover.sh" in text - assert "AGENT_SCAN_BIN='/usr/local/bin/snyk-agent-scan'" in text + assert "AGENT_SCAN_COMMAND='/usr/local/bin/snyk-agent-scan'" in text - def test_guard_install_without_agent_scan_bin_warns_and_removes_stale_discovery_script(self, tmp_path): + 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) @@ -2499,7 +2598,7 @@ def test_guard_install_without_agent_scan_bin_warns_and_removes_stale_discovery_ with ( patch(f"{_G}.IS_WINDOWS", False), - patch(f"{_G}._agent_scan_bin", return_value=None), + patch(f"{_G}._agent_scan_command", return_value=None), patch(f"{_G}._send_test_event", return_value=True), patch(f"{_G}.rich") as rich, ): @@ -2519,7 +2618,7 @@ def test_guard_install_without_agent_scan_bin_warns_and_removes_stale_discovery_ assert not discover_script.exists() assert "snyk-agent-guard-discover" not in path.read_text() - assert any("AGENT_SCAN_BIN is not set" in call.args[0] for call in rich.print.call_args_list if call.args) + 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() @@ -2871,7 +2970,7 @@ def test_stdin_payload_and_arguments_reach_the_child(self, tmp_path): result = self._run( script, - ["-AgentScanBin", str(stub), "-MachineId", "machine-42"], + ["-AgentScanCommand", str(stub), "-MachineId", "machine-42"], {**os.environ, "MARKER": str(marker)}, payload=payload, ) @@ -2888,26 +2987,26 @@ def test_nonzero_discovery_exit_is_swallowed(self, tmp_path): result = self._run( script, - ["-AgentScanBin", str(stub), "-MachineId", "machine-42"], + ["-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_binary(self, tmp_path): + 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, ["-AgentScanBin", str(stub)], env) + result = self._run(script, ["-AgentScanCommand", str(stub)], env) assert result.returncode == 0 assert not marker.exists() - def test_stale_absolute_binary_does_not_fall_back_to_path(self, tmp_path): + 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() @@ -2921,14 +3020,14 @@ def test_stale_absolute_binary_does_not_fall_back_to_path(self, tmp_path): result = self._run( script, - ["-AgentScanBin", str(tmp_path / "deleted" / "snyk-agent-scan.exe"), "-MachineId", "machine-42"], + ["-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_binary_does_not_fall_back_to_path(self, tmp_path): + 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() @@ -2940,7 +3039,7 @@ def test_unset_binary_does_not_fall_back_to_path(self, tmp_path): "MACHINE_ID": "machine-42", "PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}", } - env.pop("AGENT_SCAN_BIN", None) + env.pop("AGENT_SCAN_COMMAND", None) result = self._run(script, [], env) @@ -3386,7 +3485,7 @@ def ctx(self): dest = MagicMock(name="dest_path") targets = { "copy": (f"{_G}._copy_hook_script", _NO_RETURN_VALUE), - "agent_scan_bin": (f"{_G}._agent_scan_bin", "/usr/local/bin/snyk-agent-scan"), + "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)), @@ -3484,7 +3583,7 @@ def test_claude_builds_and_prepares_async_discovery_hook(self, ctx, tmp_path): ctx["build_discover"].assert_called_once() assert ctx["build_discover"].call_args.kwargs == { - "agent_scan_bin": "/usr/local/bin/snyk-agent-scan", + "agent_scan_command": "/usr/local/bin/snyk-agent-scan", "tenant_id": "tid-1", "machine_id": "machine-42", "hook_client": "claude-code", @@ -3525,8 +3624,8 @@ def test_codex_managed_builds_discovery_hook(self, ctx, tmp_path): ] assert ctx["prep_codex_managed"].call_args.kwargs["discover_command"] == "discover-cmd" - def test_unset_agent_scan_bin_skips_discovery_without_aborting(self, ctx, tmp_path): - ctx["agent_scan_bin"].return_value = None + 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") @@ -3538,19 +3637,19 @@ def test_unset_agent_scan_bin_skips_discovery_without_aborting(self, ctx, tmp_pa "client, hook_client", [("claude", "claude-code"), ("cursor", "cursor"), ("codex", "codex")], ) - def test_unset_agent_scan_bin_warns_once_per_client(self, ctx, tmp_path, client, hook_client): - ctx["agent_scan_bin"].return_value = None + 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_BIN is not set" in message] + 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_BIN is not set; " + "[yellow]Warning:[/yellow] AGENT_SCAN_COMMAND is not set; " "the session-start discovery hook will not be installed" ] - def test_unset_agent_scan_bin_removes_stale_script_after_config_write(self, ctx, tmp_path): - ctx["agent_scan_bin"].return_value = None + 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") @@ -3566,8 +3665,8 @@ def assert_stale_script_still_exists(*_args): 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_bin_keeps_stale_script_when_test_event_fails(self, ctx, tmp_path): - ctx["agent_scan_bin"].return_value = None + 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) From 796b0d56bee00262280268639a8160f71745f0e5 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 16:35:53 +0200 Subject: [PATCH 48/58] refactor: derive uninstall missing-file label from the path _uninstall_hooks took a missing_label argument used only in the "Nothing to uninstall" message. path.name yields the same string for every caller and is accurate for managed and --file paths, where the hardcoded label named a file that was never checked. --- src/agent_scan/guard.py | 4 +--- tests/unit/test_guard.py | 8 +++++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index d4b10cf1..35c11f07 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -932,7 +932,6 @@ def _uninstall_single_client(client: str, args, managed: bool) -> None: _uninstall_hooks( config_path, filter_hooks=_filter_cursor_hooks if client == "cursor" else _filter_claude_hooks, - missing_label="settings.json" if client == "claude" else "hooks.json", prune_empty_hooks=client != "cursor", ) @@ -969,11 +968,10 @@ def _uninstall_hooks( path: Path, *, filter_hooks: Callable[[dict], dict], - missing_label: str, prune_empty_hooks: bool, ) -> None: if not path.exists(): - rich.print(f"[dim]No {missing_label} found. Nothing to uninstall.[/dim]") + rich.print(f"[dim]No {path.name} found. Nothing to uninstall.[/dim]") return data = _read_json_or_empty(path) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 5e324f0d..ef735731 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -123,7 +123,6 @@ def _uninstall_test_client(client: str, path: Path) -> None: _uninstall_hooks( path, filter_hooks=_filter_cursor_hooks if client == "cursor" else _filter_claude_hooks, - missing_label="settings.json" if client == "claude" else "hooks.json", prune_empty_hooks=client != "cursor", ) @@ -2342,6 +2341,13 @@ def _events_for(client: str) -> list[str]: "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) From b97d94350c1c96b9e142278f2e5d21c12d786de4 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 16:39:46 +0200 Subject: [PATCH 49/58] chore: comment clean-up --- src/agent_scan/hooks/snyk-agent-guard-discover.ps1 | 1 - src/agent_scan/hooks/snyk-agent-guard-discover.sh | 1 - 2 files changed, 2 deletions(-) diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 index e1176250..8af0fe94 100644 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.ps1 @@ -46,7 +46,6 @@ try { if (Test-Path -LiteralPath $cmd -PathType Leaf) { & $cmd @arguments *> $null } else { - # TODO: ProdSec needs to review this shell-evaluation path before release. Invoke-Expression "$cmd $($arguments -join ' ')" *> $null } } catch { diff --git a/src/agent_scan/hooks/snyk-agent-guard-discover.sh b/src/agent_scan/hooks/snyk-agent-guard-discover.sh index 1c1a4a12..ba573601 100755 --- a/src/agent_scan/hooks/snyk-agent-guard-discover.sh +++ b/src/agent_scan/hooks/snyk-agent-guard-discover.sh @@ -5,7 +5,6 @@ set -euo pipefail if [[ -x "$AGENT_SCAN_COMMAND" ]]; then "$AGENT_SCAN_COMMAND" guard discover "$@" >/dev/null 2>&1 || true else - # TODO: ProdSec needs to review this shell-evaluation path before release. eval "$AGENT_SCAN_COMMAND guard discover \"\$@\"" >/dev/null 2>&1 || true fi exit 0 From 9b5b6c72b94501dccb67a09f22e0f1c615156966 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 16:44:03 +0200 Subject: [PATCH 50/58] test: fix test --- tests/unit/test_guard.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index ef735731..419da09f 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -839,7 +839,8 @@ def test_preserved_note_omitted_when_zero(self, tmp_path, capsys): def test_preserved_note_included_when_nonzero(self, tmp_path, capsys): _write_config({"hooks": {}}, tmp_path / "hooks.json", 2) - assert "(2 other hook(s) preserved)" in capsys.readouterr().out + # 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" From 43215e96e81c33e62bcfd0f9ccf20cd737df2c0d Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 16:52:39 +0200 Subject: [PATCH 51/58] test: fix test --- tests/unit/test_guard.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 419da09f..5acdf7ce 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -870,7 +870,6 @@ def test_copy_writes_executable_discovery_script_next_to_forwarder(self, tmp_pat 'if [[ -x "$AGENT_SCAN_COMMAND" ]]; then\n' ' "$AGENT_SCAN_COMMAND" guard discover "$@" >/dev/null 2>&1 || true\n' "else\n" - " # TODO: ProdSec needs to review this shell-evaluation path before release.\n" ' eval "$AGENT_SCAN_COMMAND guard discover \\"\\$@\\"" >/dev/null 2>&1 || true\n' "fi\n" "exit 0\n" From 2801de7d6ac0c3519a59536e2f933d1b01bc6c7d Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 17:24:33 +0200 Subject: [PATCH 52/58] test: compare rendered argv against str(Path) The posix _render_argv tests force IS_WINDOWS=False but built the script path with Path(), whose flavour follows the host. On the Windows runner that stringifies with backslashes, so the hardcoded POSIX expectation failed. Compare against str(script_path) instead, matching the sibling Windows test. --- tests/unit/test_guard.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 5acdf7ce..9cb6cad0 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -480,8 +480,9 @@ def test_preserves_multi_word_shell_command(self, is_windows, expected): 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=Path("/hooks/snyk-agent-guard.sh"), + script_path=script_path, hook_client="claude-code", push_key="pk'raw", url="https://example.test/hook's", @@ -491,7 +492,8 @@ def test_render_argv_posix_returns_unquoted_argv_and_merged_environment(self): 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", "/hooks/snyk-agent-guard.sh", "--client", "claude-code"] + # 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", @@ -528,8 +530,9 @@ def test_render_argv_windows_returns_unquoted_argv_without_environment(self): 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=Path("/hooks/snyk-agent-guard-discover.sh"), + script_path=script_path, hook_client="cursor", push_key="pk", url="https://api.snyk.io", @@ -545,7 +548,7 @@ def test_render_argv_posix_carries_discovery_fields(self): assert argv == [ "bash", - "/hooks/snyk-agent-guard-discover.sh", + str(script_path), "--client", "cursor", "--scope", From 60f553421b3e837003bcf21423b143f68f052507 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Wed, 26 Aug 2026 17:24:33 +0200 Subject: [PATCH 53/58] docs: document guard discover --scope --- docs/cli-reference.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 147ac9b1..6e9ad63e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -380,6 +380,7 @@ to Agent Monitor; it is not normally run by hand. | --- | --- | --- | --- | | `--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` From 91bc5db1621735781e7d6760758b6e96c52cdab2 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 27 Aug 2026 07:17:17 +0200 Subject: [PATCH 54/58] fix: escape Codex managed hooks TOML --- src/agent_scan/guard.py | 102 ++++++++++++++++++++++++++++++++---- tests/unit/test_guard.py | 108 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 199 insertions(+), 11 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 35c11f07..ceff3cd1 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -20,6 +20,16 @@ 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 ( @@ -47,7 +57,8 @@ 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 @@ -741,26 +752,30 @@ def _render_codex_requirements_toml( "hooks = true", "", "[hooks]", - f'managed_dir = "{managed_dir}"', - f"windows_managed_dir = '{windows_managed_dir}'", + f"managed_dir = {_toml_basic_string(managed_dir)}", + f"windows_managed_dir = {_toml_basic_string(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_basic_string(command)}") lines.append("") if discover_command: - escaped_discover = discover_command.replace("\\", "\\\\").replace('"', '\\"') lines.append("[[hooks.SessionStart]]") lines.append("[[hooks.SessionStart.hooks]]") lines.append('type = "command"') - lines.append(f'command = "{escaped_discover}"') + lines.append(f"command = {_toml_basic_string(discover_command)}") lines.append("async = true") lines.append("") - return "\n".join(lines).rstrip("\n") + "\n" + 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( @@ -860,7 +875,7 @@ def _parse_codex_requirements_toml(text: str) -> tuple[list[str], str | None, st continue m = command_re.match(line) if m and current_event: - cmd = m.group(1).replace("\\\\", "\0").replace('\\"', '"').replace("\0", "\\") + cmd = _toml_unescape(m.group(1)) if not _is_agent_scan_command(cmd): continue if current_event not in events: @@ -1691,6 +1706,75 @@ def _shell_quote(s: str) -> str: return "'" + s.replace("'", "'\"'\"'") + "'" +def _toml_basic_string(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 _ps_quote(s: str) -> str: """Quote a value for a PowerShell single-quoted literal.""" return "'" + s.replace("'", "''") + "'" diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 9cb6cad0..a638f4b9 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -2504,8 +2504,8 @@ def test_render_none_preserves_existing_output(self, tmp_path): "hooks = true", "", "[hooks]", - f'managed_dir = "{managed_dir}"', - f"windows_managed_dir = '{windows_managed_dir}'", + f"managed_dir = {json.dumps(managed_dir)}", + f"windows_managed_dir = {json.dumps(windows_managed_dir)}", "", ] for event in CODEX_HOOK_EVENTS: @@ -2538,6 +2538,47 @@ def test_rendered_discovery_toml_is_valid(self, tmp_path): {"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( @@ -2552,6 +2593,69 @@ def test_render_parse_round_trip_splits_guard_and_discovery_commands(self, tmp_p assert guard_command == CODEX_AGENT_SCAN_CMD assert discover_command == CODEX_DISCOVER_CMD + 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" + 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": {}} + + @pytest.mark.parametrize( + "value", + [ + "", + "\b\t\n\f\r", + "\x00\x1f\x7f", + "Grüezi, 世界", + "\\\\\\", + 'embedded "quotes"', + ], + ) + def test_toml_basic_string_and_unescape_are_exact_inverses(self, value): + rendered = guard_module._toml_basic_string(value) + + assert guard_module._toml_unescape(rendered[1:-1]) == value + def test_install_writes_toml(self, tmp_path): install, _, _, _ = self._import_managed_helpers() path = tmp_path / "requirements.toml" From 55526ccc6d29561f700b79eb719ff93db16defdb Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 27 Aug 2026 07:42:34 +0200 Subject: [PATCH 55/58] refactor: move TOML escaping to utils --- src/agent_scan/guard.py | 80 +++------------------------------------- src/agent_scan/utils.py | 69 ++++++++++++++++++++++++++++++++++ tests/unit/test_guard.py | 16 -------- tests/unit/test_utils.py | 19 ++++++++++ 4 files changed, 94 insertions(+), 90 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index ceff3cd1..93ff608b 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -40,6 +40,7 @@ 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 @@ -752,21 +753,21 @@ def _render_codex_requirements_toml( "hooks = true", "", "[hooks]", - f"managed_dir = {_toml_basic_string(managed_dir)}", - f"windows_managed_dir = {_toml_basic_string(windows_managed_dir)}", + f"managed_dir = {toml_escape(managed_dir)}", + f"windows_managed_dir = {toml_escape(windows_managed_dir)}", "", ] 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 = {_toml_basic_string(command)}") + lines.append(f"command = {toml_escape(command)}") lines.append("") if discover_command: lines.append("[[hooks.SessionStart]]") lines.append("[[hooks.SessionStart.hooks]]") lines.append('type = "command"') - lines.append(f"command = {_toml_basic_string(discover_command)}") + lines.append(f"command = {toml_escape(discover_command)}") lines.append("async = true") lines.append("") content = "\n".join(lines).rstrip("\n") + "\n" @@ -875,7 +876,7 @@ def _parse_codex_requirements_toml(text: str) -> tuple[list[str], str | None, st continue m = command_re.match(line) if m and current_event: - cmd = _toml_unescape(m.group(1)) + cmd = toml_unescape(m.group(1)) if not _is_agent_scan_command(cmd): continue if current_event not in events: @@ -1706,75 +1707,6 @@ def _shell_quote(s: str) -> str: return "'" + s.replace("'", "'\"'\"'") + "'" -def _toml_basic_string(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 _ps_quote(s: str) -> str: """Quote a value for a PowerShell single-quoted literal.""" return "'" + s.replace("'", "''") + "'" diff --git a/src/agent_scan/utils.py b/src/agent_scan/utils.py index f0e62776..3596bb00 100644 --- a/src/agent_scan/utils.py +++ b/src/agent_scan/utils.py @@ -62,6 +62,75 @@ 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: original_path = path.replace("\\", "/") diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index a638f4b9..d1451183 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -2640,22 +2640,6 @@ def test_write_then_prepare_same_control_commands_has_no_modified_diff(self, tmp assert diff == {"added": {}, "modified": {}, "removed": {}} - @pytest.mark.parametrize( - "value", - [ - "", - "\b\t\n\f\r", - "\x00\x1f\x7f", - "Grüezi, 世界", - "\\\\\\", - 'embedded "quotes"', - ], - ) - def test_toml_basic_string_and_unescape_are_exact_inverses(self, value): - rendered = guard_module._toml_basic_string(value) - - assert guard_module._toml_unescape(rendered[1:-1]) == value - def test_install_writes_toml(self, tmp_path): install, _, _, _ = self._import_managed_helpers() path = tmp_path / "requirements.toml" diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index bb6246bf..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("~") From 4c324840a123f7022e23d5c8c7a7282518b03724 Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 27 Aug 2026 08:18:07 +0200 Subject: [PATCH 56/58] refactor: rename server discovery hook events --- docs/cli-reference.md | 7 ++++--- src/agent_scan/cli.py | 2 +- src/agent_scan/guard.py | 9 +++++++-- tests/e2e/test_guard_install.py | 8 ++++---- tests/unit/test_guard.py | 10 +++++----- tests/unit/test_hook_events.py | 2 +- 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6e9ad63e..5fe1a9c4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -355,7 +355,8 @@ snyk-agent-scan guard snyk-agent-scan guard install {claude,cursor,codex,all} [OPTIONS] ``` -Installation also configures a fire-and-forget session-start hook that reports discovered MCP servers. +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 | | --- | --- | --- | --- | @@ -373,8 +374,8 @@ 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 event directly -to Agent Monitor; it is not normally run by hand. +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 | | --- | --- | --- | --- | diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index b9ac647c..a00ca014 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -974,7 +974,7 @@ def main(): "discover", allow_abbrev=False, help=( - "Run MCP server discovery and send a SessionStartServerDiscovery event directly to Agent Monitor " + "Run MCP server discovery and send a sessionStartServerDiscovery event directly to Agent Monitor " "(used by the async session-start hooks that guard install configures)" ), ) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 93ff608b..bd6243e2 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -402,7 +402,7 @@ def _run_discover(args) -> int: url, hook_client, machine_id, - event_name="SessionStartServerDiscovery", + event_name="sessionStartServerDiscovery", session_marker=session_id or "session-start-server-discovery", target_folders=target_folders, discovery_scope=getattr(args, "scope", DiscoveryScope.ALL), @@ -1296,12 +1296,17 @@ def _send_servers_discovered_event( hook_client: str, machine_id: str, *, - event_name: str = "serversDiscovered", + 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: diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index fc02e3b3..616b0c20 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -93,7 +93,7 @@ def test_guard_install_claude(self, agent_scan_cmd, agent_scan_command, tmp_path assert ("-ConfigFile" if os.name == "nt" else "--file") not in discover_command assert [request["body"]["hook_event_name"] for request in _FakeHookServer.requests] == [ "hooksConfigured", - "serversDiscovered", + "hooksConfiguredServerDiscovery", ] discovered = _FakeHookServer.requests[1] assert discovered["body"]["session_id"] == "hooks-setup" @@ -118,7 +118,7 @@ def test_guard_install_claude(self, agent_scan_cmd, agent_scan_command, tmp_path 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"]["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) @@ -177,7 +177,7 @@ def test_guard_install_cursor(self, agent_scan_cmd, agent_scan_command, tmp_path 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"]["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) @@ -239,7 +239,7 @@ def test_guard_install_codex(self, agent_scan_cmd, agent_scan_command, tmp_path, 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"]["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) diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index d1451183..36f508e0 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -2884,7 +2884,7 @@ 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": "serversDiscovered", + "hook_event_name": "hooksConfiguredServerDiscovery", "session_id": "s1", "servers": ["x" * (1024 * 1024)], } @@ -4552,7 +4552,7 @@ def test_payload_contract_for_client(self, hook_client, id_key): assert ok is True payload = captured["payload"] - assert payload["hook_event_name"] == "serversDiscovered" + 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**'" @@ -4586,12 +4586,12 @@ def fake_send(_url, _client, _push_key, payload, _machine_id, **kwargs): "https://api.snyk.io", "claude-code", "machine-42", - event_name="SessionStartServerDiscovery", + event_name="sessionStartServerDiscovery", session_marker="session-start-server-discovery", ) assert ok is True - assert captured["payload"]["hook_event_name"] == "SessionStartServerDiscovery" + 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): @@ -4825,7 +4825,7 @@ def fake_send(url, client, push_key, payload, machine_id, **kwargs): assert isinstance(duration, int) assert duration >= 0 assert captured["payload"] == { - "hook_event_name": "SessionStartServerDiscovery", + "hook_event_name": "sessionStartServerDiscovery", "servers": [], "session_id": "session-start-server-discovery", } diff --git a/tests/unit/test_hook_events.py b/tests/unit/test_hook_events.py index d8e11b0d..b88bc7d0 100644 --- a/tests/unit/test_hook_events.py +++ b/tests/unit/test_hook_events.py @@ -54,7 +54,7 @@ def _patch_session(session: _FakeSession): @pytest.mark.parametrize("client", ["claude-code", "cursor", "codex"]) def test_sends_existing_hook_wire_contract(client): session = _FakeSession() - payload = '{"hook_event_name":"serversDiscovered"}' + payload = '{"hook_event_name":"hooksConfiguredServerDiscovery"}' with ( patch("agent_scan.hook_events.get_hostname", return_value="host-1"), From bc33614d0f59b957519f4b3f5654534a3e3f39da Mon Sep 17 00:00:00 2001 From: iamcristi Date: Thu, 27 Aug 2026 15:03:38 +0200 Subject: [PATCH 57/58] fix: default schemeless hook URLs to HTTP --- src/agent_scan/hook_events.py | 3 +++ tests/unit/test_hook_events.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/agent_scan/hook_events.py b/src/agent_scan/hook_events.py index 7f8d0fb4..f35e7acb 100644 --- a/src/agent_scan/hook_events.py +++ b/src/agent_scan/hook_events.py @@ -82,6 +82,9 @@ def send_hook_event( ) 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}", diff --git a/tests/unit/test_hook_events.py b/tests/unit/test_hook_events.py index b88bc7d0..e39b2c4a 100644 --- a/tests/unit/test_hook_events.py +++ b/tests/unit/test_hook_events.py @@ -80,6 +80,27 @@ def test_sends_existing_hook_wire_contract(client): } +@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() From 70eead6f21c6909eb0ff714ef6117076a90b689a Mon Sep 17 00:00:00 2001 From: Cristian Petrescu Date: Fri, 28 Aug 2026 09:03:52 +0200 Subject: [PATCH 58/58] fix: add temporary discovery install fallbacks (#454) --- src/agent_scan/guard.py | 35 ++++++++++--- tests/e2e/test_guard_install.py | 12 +++-- tests/unit/test_guard.py | 92 ++++++++++++++++++++++++++++----- 3 files changed, 115 insertions(+), 24 deletions(-) diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index bd6243e2..f3cb0960 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -213,11 +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) - # for the release i'll create a new PR to add hostname as default for machine_id but i'll make it separate to be clear. machine_id needs to be mandatory if we want to reliably identify the servers machine_id = (getattr(args, "machine_id", None) or os.environ.get("MACHINE_ID", "") or "").strip() if not machine_id: - rich.print("[bold red]Error:[/bold red] --machine-id is required (or set the MACHINE_ID environment variable).") - sys.exit(1) + # 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] @@ -506,9 +511,15 @@ def _install_hooks( 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 + 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 agent_scan_command is 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" @@ -1659,8 +1670,20 @@ def _build_hook_command( def _agent_scan_command() -> str | None: - """The configured command the session-start hook should invoke, if any.""" - return os.environ.get("AGENT_SCAN_COMMAND", "").strip() or 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( diff --git a/tests/e2e/test_guard_install.py b/tests/e2e/test_guard_install.py index 616b0c20..efef1f7f 100644 --- a/tests/e2e/test_guard_install.py +++ b/tests/e2e/test_guard_install.py @@ -53,8 +53,11 @@ class TestGuardInstallE2E: """ @pytest.mark.parametrize("agent_scan_cmd", ["uv", "binary"], indirect=True) - def test_guard_install_claude(self, agent_scan_cmd, agent_scan_command, tmp_path, fake_hook_server): + 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, @@ -65,13 +68,11 @@ def test_guard_install_claude(self, agent_scan_cmd, agent_scan_command, tmp_path 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)}, + env=install_env, ) assert result.returncode == 0, f"guard install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" @@ -100,7 +101,8 @@ def test_guard_install_claude(self, agent_scan_cmd, agent_scan_command, tmp_path assert isinstance(discovered["body"]["servers"], list) assert isinstance(discovered["body"]["discovery_duration_ms"], int) assert discovered["body"]["discovery_duration_ms"] >= 0 - assert json.loads(discovered["headers"]["X-User"])["identifier"] == "e2e-machine-id" + 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"], diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 36f508e0..6394f7ba 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -379,19 +379,66 @@ def test_roundtrip_extract(self): 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_returns_none_when_environment_unset(self, monkeypatch): + 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() is None + assert guard_module._agent_scan_command() == str(executable.absolute()) @pytest.mark.parametrize("value", ["", " "]) - def test_returns_none_when_environment_value_is_blank(self, monkeypatch, 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() is None + 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: @@ -3721,6 +3768,14 @@ def test_codex_managed_builds_discovery_hook(self, ctx, tmp_path): ] 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 @@ -5274,19 +5329,30 @@ def test_machine_id_precedence_reaches_install_and_send( assert install.call_args.args[-1] == expected assert send.call_args.args[-1] == expected - def test_missing_machine_id_aborts_before_install(self, tmp_path, monkeypatch): + @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") - monkeypatch.delenv("MACHINE_ID", raising=False) + 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") as install, - patch(f"{_G}._send_servers_discovered_event") as send, - pytest.raises(SystemExit) as exc, + 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=None)) + _run_install(self._args(tmp_path, machine_id=arg_machine_id)) - assert exc.value.code == 1 - install.assert_not_called() - send.assert_not_called() + 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")