Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions src/agent_scan/agents/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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``.
Expand All @@ -134,7 +135,7 @@ def _config_json_path(self) -> Path:
``<base>/.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
Expand Down
11 changes: 6 additions & 5 deletions src/agent_scan/agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ``<codex_home>/config.toml`` (``None`` if missing/empty,
Expand Down
56 changes: 56 additions & 0 deletions src/agent_scan/client_paths.py
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +23 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Relative override path writes 🐞 Bug ⛨ Security

If CLAUDE_CONFIG_DIR/CODEX_HOME is set to a relative path, resolve_user_client_dir() returns
it as-is and guard builds config/script paths relative to the current working directory, causing
hook install/uninstall/status to read/write the wrong location. This can create or delete files in
unexpected directories and makes behavior dependent on the process CWD.
Agent Prompt
### Issue description
`user_client_dir_override()` returns `Path(value).expanduser()` without ensuring the result is absolute. When the env var is set to a relative path (e.g. `CODEX_HOME=custom-codex`), downstream code (notably `guard._config_path()` and `_copy_hook_script()`) will create directories and write files relative to the current working directory.

### Issue Context
This PR newly makes guard honor `CLAUDE_CONFIG_DIR` and `CODEX_HOME` via `resolve_user_client_dir()`, so the relative-path edge case now affects guard install/uninstall/status behavior.

### Fix Focus Areas
- src/agent_scan/client_paths.py[15-52]
- src/agent_scan/guard.py[1195-1206]

### Suggested fix
- In `user_client_dir_override()` (or in `resolve_user_client_dir()` right after reading `configured`), normalize the path:
  - `p = Path(value).expanduser()`
  - If `not p.is_absolute()`: either
    - raise a `ValueError` with a clear message requiring an absolute path, **or**
    - treat it as relative to the scanning user’s home (e.g. `p = (Path.home() / p)`), then return `p`.
- Add a unit test for guard ensuring a relative `CODEX_HOME`/`CLAUDE_CONFIG_DIR` is rejected or anchored deterministically (depending on chosen behavior).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



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
60 changes: 37 additions & 23 deletions src/agent_scan/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import rich

from agent_scan.client_paths import resolve_user_client_dir
from agent_scan.pushkeys import (
GuardEnabledAccessDeniedError,
_is_localhost,
Expand All @@ -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",
}
Comment on lines +44 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

2. Client path metadata duplication 🐞 Bug ⚙ Maintainability

Client directory/default metadata now exists in multiple places (_CLIENT_DIR_SETTINGS and guard’s
_CLIENT_CONFIG_FILENAMES), which can drift and cause runtime ValueError/KeyError if a client
is added/renamed in only one mapping. This increases maintenance risk for future client additions or
path changes.
Agent Prompt
### Issue description
Client path configuration is split across multiple dicts:
- `client_paths._CLIENT_DIR_SETTINGS` (env var + default dir)
- `guard._CLIENT_CONFIG_FILENAMES` (config filename)
This duplication can drift, causing failures when one mapping is updated without the other.

### Issue Context
This PR introduced `client_paths.py` specifically to centralize runtime directory resolution, but guard still carries a separate client mapping for filenames.

### Fix Focus Areas
- src/agent_scan/client_paths.py[8-52]
- src/agent_scan/guard.py[44-48]

### Suggested fix
- Create a single source of truth (e.g. a `ClientPathSpec` dataclass) in `client_paths.py` that includes env var, default dir, and (optionally) config filename.
- Import and use that spec in guard (and optionally in discoverers) to avoid parallel mappings.
- Add a small assertion test ensuring all clients supported by guard are present in the shared spec (and vice versa, if appropriate).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

_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
Expand Down Expand Up @@ -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]")
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/test_agent_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading