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",