diff --git a/src/agent_scan/agents/claude_code.py b/src/agent_scan/agents/claude_code.py index 89c4ddb5..82b7e5aa 100644 --- a/src/agent_scan/agents/claude_code.py +++ b/src/agent_scan/agents/claude_code.py @@ -13,6 +13,7 @@ SkillsDirsResult, _walk_under_depth, ) +from agent_scan.client_paths import resolve_user_client_dir, user_client_dir_override from agent_scan.models import ( ClaudeConfigFile, CouldNotParseMCPConfig, @@ -121,11 +122,11 @@ def _claude_base_dir(self) -> Path: the scanner can't know each *other* target user's env, so the per-home default is used instead. """ - if self._scans_own_home(): - config_dir = os.environ.get("CLAUDE_CONFIG_DIR") - if config_dir: - return Path(config_dir) - return expand_path(Path(self._install_path), self.home_directory) + return resolve_user_client_dir( + "claude", + home_directory=self.home_directory, + honor_environment=self._scans_own_home(), + ) def _config_json_path(self) -> Path: """Path to the global ``.claude.json``. @@ -134,7 +135,7 @@ def _config_json_path(self) -> Path: ``/.claude.json``; falls back to the legacy ``~/.claude.json`` when that relocated file does not exist. """ - if self._scans_own_home() and os.environ.get("CLAUDE_CONFIG_DIR"): + if self._scans_own_home() and user_client_dir_override("claude") is not None: relocated = self._claude_base_dir() / ".claude.json" if relocated.exists(): return relocated diff --git a/src/agent_scan/agents/codex.py b/src/agent_scan/agents/codex.py index 7895912d..dcd5cf47 100644 --- a/src/agent_scan/agents/codex.py +++ b/src/agent_scan/agents/codex.py @@ -30,6 +30,7 @@ SkillsDirsResult, _walk_under_depth, ) +from agent_scan.client_paths import resolve_user_client_dir from agent_scan.models import ( ClaudeConfigFile, CouldNotParseMCPConfig, @@ -380,11 +381,11 @@ def _codex_home(self) -> Path: only on an own-home scan — under ``--scan-all-users`` the scanner can't know another user's env. Mirrors ``ClaudeCodeDiscoverer``'s ``CLAUDE_CONFIG_DIR``. """ - if self._scans_own_home(): - codex_home = os.environ.get("CODEX_HOME") - if codex_home: - return Path(codex_home) - return expand_path(Path(self._install_path), self.home_directory) + return resolve_user_client_dir( + "codex", + home_directory=self.home_directory, + honor_environment=self._scans_own_home(), + ) def _user_config_toml(self) -> dict | CouldNotParseMCPConfig | None: """Read and TOML-decode ``/config.toml`` (``None`` if missing/empty, diff --git a/src/agent_scan/client_paths.py b/src/agent_scan/client_paths.py new file mode 100644 index 00000000..064f4b9c --- /dev/null +++ b/src/agent_scan/client_paths.py @@ -0,0 +1,56 @@ +"""Runtime resolution of user-level client configuration directories.""" + +from __future__ import annotations + +import os +from pathlib import Path + +_CLIENT_DIR_SETTINGS: dict[str, tuple[str | None, str]] = { + "claude": ("CLAUDE_CONFIG_DIR", ".claude"), + "cursor": (None, ".cursor"), + "codex": ("CODEX_HOME", ".codex"), +} + + +def user_client_dir_override(client: str) -> Path | None: + """Return a non-empty environment path with its user marker expanded.""" + try: + env_var, _ = _CLIENT_DIR_SETTINGS[client] + except KeyError as exc: + raise ValueError(f"Unknown client: {client}") from exc + if env_var is None: + return None + value = os.environ.get(env_var) + if not value: + return None + if value == "~": + return Path(os.environ.get("HOME") or Path.home()) + if value.startswith(("~/", "~\\")) and os.environ.get("HOME"): + return Path(os.environ["HOME"]) / value[2:] + return Path(value).expanduser() + + +def resolve_user_client_dir( + client: str, + *, + home_directory: Path | None = None, + honor_environment: bool = True, +) -> Path: + """Resolve a client's user-level configuration directory at call time. + + ``home_directory`` supports discovery of a specific user's default directory. + Environment overrides describe only the current process's user, so callers + scanning another user's home must pass ``honor_environment=False``. + """ + try: + env_var, default_dir = _CLIENT_DIR_SETTINGS[client] + except KeyError as exc: + raise ValueError(f"Unknown client: {client}") from exc + + if honor_environment and env_var: + configured = user_client_dir_override(client) + if configured is not None: + return configured + + home = Path.home() if home_directory is None else home_directory + return home / default_dir diff --git a/src/agent_scan/guard.py b/src/agent_scan/guard.py index 818bd4d8..e59ff68c 100644 --- a/src/agent_scan/guard.py +++ b/src/agent_scan/guard.py @@ -17,6 +17,7 @@ import rich +from agent_scan.client_paths import resolve_user_client_dir from agent_scan.pushkeys import ( GuardEnabledAccessDeniedError, _is_localhost, @@ -40,9 +41,13 @@ ) _PERMISSION_DENIED = "__permission_denied__" -CLAUDE_SETTINGS_PATH = Path.home() / ".claude" / "settings.json" -CURSOR_HOOKS_PATH = Path.home() / ".cursor" / "hooks.json" -CODEX_HOOKS_PATH = Path.home() / ".codex" / "hooks.json" +_CLIENT_CONFIG_FILENAMES = { + "claude": "settings.json", + "cursor": "hooks.json", + "codex": "hooks.json", +} +_CLIENT_DEFAULT_DIRNAMES = {"claude": ".claude", "cursor": ".cursor", "codex": ".codex"} +_CLIENT_HOME_ENV_VARS = {"claude": "CLAUDE_CONFIG_DIR", "codex": "CODEX_HOME"} # Managed (MDM / admin-deployed) config paths — OS-specific # Codex managed hooks use a requirements.toml file at a system location @@ -792,11 +797,14 @@ def _uninstall_cursor(path: Path) -> None: def _run_status() -> None: rich.print("[bold]User-level hooks:[/bold]") - _print_client_status("Claude Code", CLAUDE_SETTINGS_PATH, _detect_claude_install()) + claude_path = _config_path("claude") + _print_client_status("Claude Code", claude_path, _detect_claude_install(claude_path)) rich.print() - _print_client_status("Cursor", CURSOR_HOOKS_PATH, _detect_cursor_install()) + cursor_path = _config_path("cursor") + _print_client_status("Cursor", cursor_path, _detect_cursor_install(cursor_path)) rich.print() - _print_client_status("Codex", CODEX_HOOKS_PATH, _detect_codex_install()) + codex_path = _config_path("codex") + _print_client_status("Codex", codex_path, _detect_codex_install(codex_path)) rich.print() rich.print("[bold]Managed hooks:[/bold]") @@ -855,7 +863,9 @@ def _print_client_status(label: str, path: Path, info: dict | str | None) -> Non ) -def _detect_claude_install(path: Path = CLAUDE_SETTINGS_PATH) -> dict | None: +def _detect_claude_install(path: Path | None = None) -> dict | None: + if path is None: + path = _config_path("claude") if not path.exists(): return None settings = _read_json_or_empty(path) @@ -880,7 +890,9 @@ def _detect_claude_install(path: Path = CLAUDE_SETTINGS_PATH) -> dict | None: return _parse_command_info(found_cmd, events) -def _detect_codex_install(path: Path = CODEX_HOOKS_PATH) -> dict | None: +def _detect_codex_install(path: Path | None = None) -> dict | None: + if path is None: + path = _config_path("codex") if not path.exists(): return None if _is_codex_requirements_toml(path): @@ -907,7 +919,9 @@ def _detect_codex_install(path: Path = CODEX_HOOKS_PATH) -> dict | None: return _parse_command_info(found_cmd, events) -def _detect_cursor_install(path: Path = CURSOR_HOOKS_PATH) -> dict | None: +def _detect_cursor_install(path: Path | None = None) -> dict | None: + if path is None: + path = _config_path("cursor") if not path.exists(): return None data = _read_json_or_empty(path) @@ -1160,18 +1174,11 @@ def _extract_env_from_cmd(cmd: str, key: str) -> str: _HOOK_CLIENT_NAMES = {"claude": "claude-code", "cursor": "cursor", "codex": "codex"} -_CLIENT_INSTALL_PATHS = { - "claude": Path.home() / ".claude", - "cursor": Path.home() / ".cursor", - "codex": Path.home() / ".codex", -} - - def _is_client_installed(client: str) -> bool: """Check whether the agent is installed on this machine by looking for its config directory.""" - path = _CLIENT_INSTALL_PATHS.get(client) - if path is None: + if client not in _CLIENT_CONFIG_FILENAMES: return True + path = _guard_user_client_dir(client) try: return path.is_dir() except PermissionError: @@ -1197,11 +1204,18 @@ def _config_path(client: str, override: str | None = None, managed: bool = False if client == "cursor": return CURSOR_MANAGED_HOOKS_PATH return CODEX_MANAGED_HOOKS_PATH - if client == "claude": - return CLAUDE_SETTINGS_PATH - if client == "cursor": - return CURSOR_HOOKS_PATH - return CODEX_HOOKS_PATH + return _guard_user_client_dir(client) / _CLIENT_CONFIG_FILENAMES[client] + + +def _guard_user_client_dir(client: str) -> Path: + """Resolve the user config directory without allowing an env override to hide an installed default.""" + configured = resolve_user_client_dir(client) + env_var = _CLIENT_HOME_ENV_VARS.get(client) + if env_var and os.environ.get(env_var): + default = Path.home() / _CLIENT_DEFAULT_DIRNAMES[client] + if configured != default and default.is_dir(): + return default + return configured def _preflight_writable(config_path: Path) -> None: diff --git a/tests/unit/test_agent_discovery.py b/tests/unit/test_agent_discovery.py index f67e3fbf..e16412b8 100644 --- a/tests/unit/test_agent_discovery.py +++ b/tests/unit/test_agent_discovery.py @@ -4921,6 +4921,20 @@ def test_claude_code_honors_claude_config_dir_on_own_home_scan(tmp_path, monkeyp assert mcp_configs[keys[0]][0][0] == "relocated" +def test_claude_code_expands_tilde_in_claude_config_dir(tmp_path, monkeypatch): + from agent_scan.agents import ClaudeCodeDiscoverer + + cfg = tmp_path / "custom-claude" + cfg.mkdir() + (cfg / ".claude.json").write_text('{"mcpServers": {"relocated": {"command": "r"}}}') + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", "~/custom-claude") + + mcp_configs = ClaudeCodeDiscoverer(None).discover_mcp_servers() + + assert (cfg / ".claude.json").as_posix() in mcp_configs + + def test_claude_code_ignores_claude_config_dir_when_home_passed(tmp_path, monkeypatch): """Under multi-user scans (an explicit home is passed) the scanning process's CLAUDE_CONFIG_DIR must NOT relocate the target user's config.""" @@ -6495,6 +6509,20 @@ def test_codex_discoverer_honors_codex_home_on_own_home_scan(tmp_path, monkeypat assert mcp_configs[keys[0]][0][0] == "relocated" +def test_codex_discoverer_expands_tilde_in_codex_home(tmp_path, monkeypatch): + from agent_scan.agents import CodexDiscoverer + + cfg = tmp_path / "custom-codex" + cfg.mkdir() + (cfg / "config.toml").write_text('[mcp_servers.relocated]\ncommand = "r"\n') + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("CODEX_HOME", "~/custom-codex") + + mcp_configs = CodexDiscoverer(None).discover_mcp_servers() + + assert (cfg / "config.toml").as_posix() in mcp_configs + + def test_codex_discoverer_ignores_codex_home_when_home_passed(tmp_path, monkeypatch): """Under a multi-user scan (an explicit, different home is passed) the scanning process's ``CODEX_HOME`` must NOT relocate the target user's config.""" diff --git a/tests/unit/test_guard.py b/tests/unit/test_guard.py index 61e46db4..d5e9d33b 100644 --- a/tests/unit/test_guard.py +++ b/tests/unit/test_guard.py @@ -20,12 +20,9 @@ ALL_CLIENTS, CLAUDE_HOOK_EVENTS, CLAUDE_MANAGED_SETTINGS_PATH, - CLAUDE_SETTINGS_PATH, CODEX_HOOK_EVENTS, - CODEX_HOOKS_PATH, CODEX_MANAGED_HOOKS_PATH, CURSOR_HOOK_EVENTS, - CURSOR_HOOKS_PATH, CURSOR_MANAGED_HOOKS_PATH, _build_hook_command, _build_hook_command_powershell, @@ -52,6 +49,7 @@ _prepare_cursor_config, _print_client_status, _run_install, + _run_status, _run_uninstall, _send_test_event, _shell_quote, @@ -98,6 +96,12 @@ def _write(path: Path, data) -> None: path.write_text(json.dumps(data, indent=2) + "\n") +def _set_test_home(monkeypatch, path: Path) -> None: + monkeypatch.setenv("HOME", str(path)) + monkeypatch.setenv("USERPROFILE", str(path)) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: path)) + + def _setup_claude_hooks(cmd: str, path: Path) -> None: settings, _, preserved = _prepare_claude_config(cmd, path) _write_claude_config(settings, path, preserved) @@ -695,25 +699,89 @@ def test_mixed(self): class TestConfigPath: - def test_claude_user_default(self): - assert _config_path("claude") == CLAUDE_SETTINGS_PATH + def test_claude_user_default(self, monkeypatch): + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + assert _config_path("claude") == Path.home() / ".claude" / "settings.json" def test_cursor_user_default(self): - assert _config_path("cursor") == CURSOR_HOOKS_PATH - - def test_codex_user_default(self): - assert _config_path("codex") == CODEX_HOOKS_PATH - - def test_claude_managed(self): + assert _config_path("cursor") == Path.home() / ".cursor" / "hooks.json" + + def test_codex_user_default(self, monkeypatch): + monkeypatch.delenv("CODEX_HOME", raising=False) + assert _config_path("codex") == Path.home() / ".codex" / "hooks.json" + + @pytest.mark.parametrize( + ("client", "env_var", "filename"), + [("claude", "CLAUDE_CONFIG_DIR", "settings.json"), ("codex", "CODEX_HOME", "hooks.json")], + ) + def test_honors_client_environment_directory(self, client, env_var, filename, tmp_path, monkeypatch): + configured = tmp_path / f"custom-{client}" + _set_test_home(monkeypatch, tmp_path / "default-home") + monkeypatch.setenv(env_var, str(configured)) + assert _config_path(client) == configured / filename + + @pytest.mark.parametrize( + ("client", "env_var", "default_dir", "filename"), + [ + ("claude", "CLAUDE_CONFIG_DIR", ".claude", "settings.json"), + ("codex", "CODEX_HOME", ".codex", "hooks.json"), + ], + ) + def test_empty_environment_directory_uses_default(self, client, env_var, default_dir, filename, monkeypatch): + monkeypatch.setenv(env_var, "") + assert _config_path(client) == Path.home() / default_dir / filename + + @pytest.mark.parametrize( + ("client", "env_var", "filename"), + [("claude", "CLAUDE_CONFIG_DIR", "settings.json"), ("codex", "CODEX_HOME", "hooks.json")], + ) + def test_environment_directory_expands_tilde(self, client, env_var, filename, tmp_path, monkeypatch): + _set_test_home(monkeypatch, tmp_path) + monkeypatch.setenv(env_var, f"~/custom-{client}") + assert _config_path(client) == tmp_path / f"custom-{client}" / filename + + def test_environment_changes_after_import_are_honored(self, tmp_path, monkeypatch): + first = tmp_path / "first" + second = tmp_path / "second" + _set_test_home(monkeypatch, tmp_path / "default-home") + monkeypatch.setenv("CODEX_HOME", str(first)) + assert _config_path("codex") == first / "hooks.json" + monkeypatch.setenv("CODEX_HOME", str(second)) + assert _config_path("codex") == second / "hooks.json" + + @pytest.mark.parametrize( + ("client", "env_var", "default_dir", "filename"), + [ + ("claude", "CLAUDE_CONFIG_DIR", ".claude", "settings.json"), + ("codex", "CODEX_HOME", ".codex", "hooks.json"), + ], + ) + def test_installed_default_directory_takes_precedence_over_environment_override( + self, client, env_var, default_dir, filename, tmp_path, monkeypatch + ): + home = tmp_path / "home" + default = home / default_dir + default.mkdir(parents=True) + configured = tmp_path / f"custom-{client}" + configured.mkdir() + _set_test_home(monkeypatch, home) + monkeypatch.setenv(env_var, str(configured)) + + assert _config_path(client) == default / filename + + def test_claude_managed(self, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "ignored")) assert _config_path("claude", managed=True) == CLAUDE_MANAGED_SETTINGS_PATH def test_cursor_managed(self): assert _config_path("cursor", managed=True) == CURSOR_MANAGED_HOOKS_PATH - def test_codex_managed(self): + def test_codex_managed(self, tmp_path, monkeypatch): + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "ignored")) assert _config_path("codex", managed=True) == CODEX_MANAGED_HOOKS_PATH - def test_file_override_takes_precedence_over_managed(self): + def test_file_override_takes_precedence_over_managed(self, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "ignored")) override = "/custom/path/settings.json" assert _config_path("claude", override=override, managed=True) == Path(override) @@ -722,6 +790,167 @@ def test_file_override_takes_precedence_over_user(self): assert _config_path("claude", override=override) == Path(override) +class TestResolvedUserPaths: + @pytest.mark.parametrize( + ("client", "hook_client", "env_var", "filename"), + [ + ("claude", "claude-code", "CLAUDE_CONFIG_DIR", "settings.json"), + ("codex", "codex", "CODEX_HOME", "hooks.json"), + ], + ) + def test_override_install_sends_hooks_configured_from_overridden_location( + self, client, hook_client, env_var, filename, tmp_path, monkeypatch + ): + home = tmp_path / "home" + configured = tmp_path / f"custom-{client}" + configured.mkdir() + _set_test_home(monkeypatch, home) + monkeypatch.setenv(env_var, str(configured)) + monkeypatch.setenv("PUSH_KEY", "headless-pk") + monkeypatch.delenv("TENANT_ID", raising=False) + + with patch("agent_scan.guard._send_test_event", return_value=True) as send_event: + _run_install( + SimpleNamespace( + client=client, + url="https://api.snyk.io", + tenant_id="", + file=None, + managed=False, + ) + ) + + assert (configured / filename).is_file() + assert send_event.call_count == 1 + assert send_event.call_args.args[2] == hook_client + assert send_event.call_args.args[3].parent == configured / "hooks" + assert any(send_event.call_args.kwargs["hooks_diff"].values()) + + @pytest.mark.parametrize( + ("client", "env_var", "default_dir", "filename"), + [ + ("claude", "CLAUDE_CONFIG_DIR", ".claude", "settings.json"), + ("codex", "CODEX_HOME", ".codex", "hooks.json"), + ], + ) + def test_existing_default_location_cannot_be_bypassed_by_environment_override( + self, client, env_var, default_dir, filename, tmp_path, monkeypatch + ): + home = tmp_path / "home" + default = home / default_dir + configured = tmp_path / f"custom-{client}" + default.mkdir(parents=True) + configured.mkdir() + _set_test_home(monkeypatch, home) + monkeypatch.setenv(env_var, str(configured)) + monkeypatch.setenv("PUSH_KEY", "headless-pk") + monkeypatch.delenv("TENANT_ID", raising=False) + + with patch("agent_scan.guard._send_test_event", return_value=True) as send_event: + _run_install( + SimpleNamespace( + client=client, + url="https://api.snyk.io", + tenant_id="", + file=None, + managed=False, + ) + ) + + assert (default / filename).is_file() + assert not (configured / filename).exists() + assert send_event.call_count == 1 + assert send_event.call_args.args[3].parent == default / "hooks" + + def test_install_uses_resolved_config_and_hook_directory(self, tmp_path, monkeypatch): + codex_home = tmp_path / "custom-codex" + codex_home.mkdir() + _set_test_home(monkeypatch, tmp_path / "default-home") + config_path = codex_home / "hooks.json" + _write( + config_path, + { + "unrelated": {"preserved": True}, + "hooks": {"CustomEvent": [_claude_group(OTHER_CMD)]}, + }, + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setenv("PUSH_KEY", "headless-pk") + monkeypatch.delenv("TENANT_ID", raising=False) + + with patch("agent_scan.guard._send_test_event", return_value=True): + _run_install( + SimpleNamespace( + client="codex", + url="https://api.snyk.io", + tenant_id="", + file=None, + managed=False, + ) + ) + + script_name = "snyk-agent-guard.ps1" if sys.platform == "win32" else "snyk-agent-guard.sh" + script_path = codex_home / "hooks" / script_name + assert script_path.is_file() + data = json.loads(config_path.read_text()) + assert data["unrelated"] == {"preserved": True} + assert data["hooks"]["CustomEvent"] == [_claude_group(OTHER_CMD)] + command = data["hooks"]["PreToolUse"][-1]["hooks"][0]["command"] + assert str(script_path) in command + + def test_uninstall_uses_resolved_config_and_preserves_unrelated_data(self, tmp_path, monkeypatch): + claude_home = tmp_path / "custom-claude" + _set_test_home(monkeypatch, tmp_path / "default-home") + config_path = claude_home / "settings.json" + _write(config_path, {"unrelated": {"preserved": True}}) + _setup_claude_hooks(AGENT_SCAN_CMD, config_path) + script_name = "snyk-agent-guard.ps1" if sys.platform == "win32" else "snyk-agent-guard.sh" + script_path = claude_home / "hooks" / script_name + script_path.parent.mkdir() + script_path.write_text("hook") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_home)) + monkeypatch.delenv("SNYK_TOKEN", raising=False) + + _run_uninstall(SimpleNamespace(client="claude", file=None, managed=False)) + + data = json.loads(config_path.read_text()) + assert data == {"unrelated": {"preserved": True}} + assert not script_path.exists() + + def test_default_detector_uses_runtime_resolved_path(self, tmp_path, monkeypatch): + codex_home = tmp_path / "custom-codex" + _set_test_home(monkeypatch, tmp_path / "default-home") + config_path = codex_home / "hooks.json" + _setup_codex_hooks(CODEX_AGENT_SCAN_CMD, config_path) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + info = _detect_codex_install() + + assert info is not None + assert info["auth_value"] == "pk-codex" + + def test_status_reports_and_inspects_resolved_paths(self, tmp_path, monkeypatch, capsys): + import agent_scan.guard as guard_module + + claude_home = tmp_path / "custom-claude" + codex_home = tmp_path / "custom-codex" + _setup_claude_hooks(AGENT_SCAN_CMD, claude_home / "settings.json") + _setup_codex_hooks(CODEX_AGENT_SCAN_CMD, codex_home / "hooks.json") + _set_test_home(monkeypatch, tmp_path / "default-home") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_home)) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setattr(guard_module, "CLAUDE_MANAGED_SETTINGS_PATH", tmp_path / "managed-claude.json") + monkeypatch.setattr(guard_module, "CURSOR_MANAGED_HOOKS_PATH", tmp_path / "managed-cursor.json") + monkeypatch.setattr(guard_module, "CODEX_MANAGED_HOOKS_PATH", tmp_path / "managed-codex.toml") + + _run_status() + + output = capsys.readouterr().out.replace("\n", "") + assert str(claude_home / "settings.json") in output + assert str(codex_home / "hooks.json") in output + assert output.count("INSTALLED") >= 2 + + class TestManagedPathConstants: def test_claude_managed_path_is_absolute(self): assert CLAUDE_MANAGED_SETTINGS_PATH.is_absolute() @@ -1509,6 +1738,34 @@ def test_interactive_mint_path_calls_ensure_with_token( mock_mint.assert_called_once() mock_install.assert_called_once() + @patch("agent_scan.guard._preflight_writable") + @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) + def test_preflight_and_install_receive_resolved_config_path( + self, mock_fetch, mock_mint, mock_install, mock_preflight, tmp_path, monkeypatch + ): + claude_home = tmp_path / "custom-claude" + claude_home.mkdir() + _set_test_home(monkeypatch, tmp_path / "default-home") + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_home)) + monkeypatch.delenv("PUSH_KEY", raising=False) + monkeypatch.setenv("SNYK_TOKEN", "snyk-from-env") + + _run_install( + SimpleNamespace( + client="claude", + url="https://api.snyk.io", + tenant_id="tid-interactive", + file=None, + managed=False, + ) + ) + + expected = claude_home / "settings.json" + mock_preflight.assert_called_once_with(expected) + assert mock_install.call_args.args[4] == expected + @patch("agent_scan.guard._install_hooks") @patch("agent_scan.guard.fetch_guard_enabled", return_value=True) def test_headless_with_push_key_skips_ensure(self, mock_fetch, mock_install, tmp_path, monkeypatch): @@ -2529,7 +2786,7 @@ def _all_clients_installed(self, tmp_path): d = tmp_path / f".{client}" d.mkdir() fake_paths[client] = d - with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", fake_paths): + with patch("agent_scan.guard.resolve_user_client_dir", side_effect=fake_paths.__getitem__): yield @patch("agent_scan.guard._install_hooks") @@ -2686,26 +2943,34 @@ def test_uninstall_all_with_file_override_exits(self): class TestIsClientInstalled: def test_installed_when_config_dir_exists(self, tmp_path): - with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", {"claude": tmp_path / ".claude"}): - (tmp_path / ".claude").mkdir() + path = tmp_path / ".claude" + path.mkdir() + with patch("agent_scan.guard.resolve_user_client_dir", return_value=path): assert _is_client_installed("claude") is True def test_not_installed_when_config_dir_missing(self, tmp_path): - with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", {"claude": tmp_path / ".claude"}): + with patch("agent_scan.guard.resolve_user_client_dir", return_value=tmp_path / ".claude"): assert _is_client_installed("claude") is False def test_not_installed_when_path_is_file_not_dir(self, tmp_path): - with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", {"claude": tmp_path / ".claude"}): - (tmp_path / ".claude").write_text("") + path = tmp_path / ".claude" + path.write_text("") + with patch("agent_scan.guard.resolve_user_client_dir", return_value=path): assert _is_client_installed("claude") is False + def test_uses_resolved_environment_directory(self, tmp_path, monkeypatch): + configured = tmp_path / "custom-codex" + configured.mkdir() + monkeypatch.setenv("CODEX_HOME", str(configured)) + assert _is_client_installed("codex") is True + def test_unknown_client_returns_true(self): assert _is_client_installed("unknown-client") is True def test_permission_error_returns_false(self, tmp_path): path = MagicMock() path.is_dir.side_effect = PermissionError("denied") - with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", {"claude": path}): + with patch("agent_scan.guard.resolve_user_client_dir", return_value=path): assert _is_client_installed("claude") is False @@ -2719,7 +2984,7 @@ class TestRunInstallSkipsUninstalledClients: @staticmethod def _fake_paths(tmp_path, installed_clients): - """Build a _CLIENT_INSTALL_PATHS dict where only *installed_clients* have real dirs.""" + """Build resolved directories where only *installed_clients* exist.""" paths = {} for client in ALL_CLIENTS: d = tmp_path / f".{client}" @@ -2736,7 +3001,8 @@ def test_single_client_not_installed_returns_gracefully( ): monkeypatch.delenv("PUSH_KEY", raising=False) monkeypatch.setenv("SNYK_TOKEN", "tok") - with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", self._fake_paths(tmp_path, [])): + paths = self._fake_paths(tmp_path, []) + with patch("agent_scan.guard.resolve_user_client_dir", side_effect=paths.__getitem__): _run_install( SimpleNamespace( client="claude", @@ -2759,7 +3025,8 @@ def test_all_clients_none_installed_returns_gracefully( ): monkeypatch.delenv("PUSH_KEY", raising=False) monkeypatch.setenv("SNYK_TOKEN", "tok") - with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", self._fake_paths(tmp_path, [])): + paths = self._fake_paths(tmp_path, []) + with patch("agent_scan.guard.resolve_user_client_dir", side_effect=paths.__getitem__): _run_install( SimpleNamespace( client="all", @@ -2782,7 +3049,8 @@ def test_all_clients_some_installed_skips_missing( ): monkeypatch.delenv("PUSH_KEY", raising=False) monkeypatch.setenv("SNYK_TOKEN", "tok") - with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", self._fake_paths(tmp_path, ["claude"])): + paths = self._fake_paths(tmp_path, ["claude"]) + with patch("agent_scan.guard.resolve_user_client_dir", side_effect=paths.__getitem__): _run_install( SimpleNamespace( client="all", @@ -2803,7 +3071,8 @@ def test_all_clients_some_installed_skips_missing( def test_headless_skips_uninstalled_client(self, mock_install, tmp_path, monkeypatch, capsys): monkeypatch.setenv("PUSH_KEY", "headless-pk") monkeypatch.setenv("TENANT_ID", "tid-hl") - with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", self._fake_paths(tmp_path, [])): + paths = self._fake_paths(tmp_path, []) + with patch("agent_scan.guard.resolve_user_client_dir", side_effect=paths.__getitem__): _run_install( SimpleNamespace( client="cursor", @@ -2823,7 +3092,8 @@ def test_headless_skips_uninstalled_client(self, mock_install, tmp_path, monkeyp def test_all_clients_all_installed_installs_all(self, mock_fetch, mock_mint, mock_install, tmp_path, monkeypatch): monkeypatch.delenv("PUSH_KEY", raising=False) monkeypatch.setenv("SNYK_TOKEN", "tok") - with patch("agent_scan.guard._CLIENT_INSTALL_PATHS", self._fake_paths(tmp_path, ALL_CLIENTS)): + paths = self._fake_paths(tmp_path, ALL_CLIENTS) + with patch("agent_scan.guard.resolve_user_client_dir", side_effect=paths.__getitem__): _run_install( SimpleNamespace( client="all",