From 76a92523af295e77d0228c4dd9ba9e981af66843 Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Tue, 23 Jun 2026 15:11:20 +0530 Subject: [PATCH 1/4] WEB-4882: honor CLAUDE_CONFIG_DIR in Claude Code hooks install Resolve the Claude config dir from --config-dir / CLAUDE_CONFIG_DIR (fallback ~/.claude) in setup.py and at hook runtime in unbound.py, so hooks, settings, the baked command path, audit log, cache, and backfill transcripts all live where Claude reads them when a custom dir is set. Co-Authored-By: Claude Opus 4.8 --- claude-code/hooks/setup.py | 98 ++++++++++++++++----------- claude-code/hooks/test_setup.py | 116 +++++++++++++++++++++++++++----- claude-code/hooks/unbound.py | 16 +++-- 3 files changed, 170 insertions(+), 60 deletions(-) diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index c031cc2..2174b20 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -61,6 +61,19 @@ def normalize_url(domain: str) -> str: return url.rstrip('/') +def _resolve_claude_config_dir(argv) -> Path: + value = None + for i, arg in enumerate(argv): + if arg == "--config-dir" and i + 1 < len(argv): + value = argv[i + 1] + break + if not value: + value = os.environ.get("CLAUDE_CONFIG_DIR") + if not value: + return Path.home() / ".claude" + return Path(value).expanduser().resolve() + + def get_shell_rc_file() -> Path: system = platform.system().lower() shell = os.environ.get("SHELL", "").lower() @@ -308,9 +321,10 @@ def write_unbound_config(api_key: str, urls: dict = None) -> bool: return False -def remove_gateway_artifacts() -> None: - """Remove ~/.claude/anthropic_key.sh if present (leftover from gateway setup).""" - key_helper_path = Path.home() / ".claude" / "anthropic_key.sh" +def remove_gateway_artifacts(config_dir: Path = None) -> None: + """Remove anthropic_key.sh if present (leftover from gateway setup).""" + config_dir = config_dir or (Path.home() / ".claude") + key_helper_path = config_dir / "anthropic_key.sh" if key_helper_path.exists(): try: key_helper_path.unlink() @@ -350,8 +364,9 @@ def rewrite_gateway_url_in_file(path: Path, gateway_url: str) -> None: debug_print(f"Could not rewrite gateway URL in {path}: {e}") -def setup_hooks(gateway_url: str = DEFAULT_GATEWAY_URL): - hooks_dir = Path.home() / ".claude" / "hooks" +def setup_hooks(gateway_url: str = DEFAULT_GATEWAY_URL, config_dir: Path = None): + config_dir = config_dir or (Path.home() / ".claude") + hooks_dir = config_dir / "hooks" script_path = hooks_dir / "unbound.py" # print("\nšŸ“„ Downloading unbound.py script...") @@ -371,9 +386,10 @@ def setup_hooks(gateway_url: str = DEFAULT_GATEWAY_URL): return True -def configure_claude_settings() -> bool: - settings_path = Path.home() / ".claude" / "settings.json" - +def configure_claude_settings(config_dir: Path = None) -> bool: + config_dir = config_dir or (Path.home() / ".claude") + settings_path = config_dir / "settings.json" + try: if settings_path.exists(): with open(settings_path, 'r', encoding='utf-8') as f: @@ -386,7 +402,7 @@ def configure_claude_settings() -> bool: if "apiKeyHelper" in settings: del settings["apiKeyHelper"] - script_path = Path.home() / ".claude" / "hooks" / "unbound.py" + script_path = config_dir / "hooks" / "unbound.py" # On Windows, invoke via the launcher and quote the path (handles spaces # like C:\Users\Jane Doe\ or C:\Program Files\). Use `py -3` if present, @@ -520,13 +536,14 @@ def _hook(entry: dict) -> dict: return False -def remove_hooks_from_settings() -> str: +def remove_hooks_from_settings(config_dir: Path = None) -> str: """Remove the unbound hooks from settings.json. Returns "cleared", "not_found", or "failed". """ - settings_path = Path.home() / ".claude" / "settings.json" - hook_command = str(Path.home() / ".claude" / "hooks" / "unbound.py") + config_dir = config_dir or (Path.home() / ".claude") + settings_path = config_dir / "settings.json" + hook_command = str(config_dir / "hooks" / "unbound.py") is_windows = platform.system().lower() == "windows" if not settings_path.exists(): @@ -591,8 +608,9 @@ def _clear_path(path: Path, label: str) -> str: return "failed" -def clear_setup() -> None: +def clear_setup(config_dir: Path = None) -> None: """Undo all changes made by the setup script.""" + config_dir = config_dir or (Path.home() / ".claude") print("=" * 60) print("Claude Code Hooks - Clearing Setup") print("=" * 60) @@ -607,15 +625,15 @@ def clear_setup() -> None: print("Failed to clear API_KEY") any_failed = True - _r = _clear_path(Path.home() / ".claude" / "hooks" / "unbound.py", "Claude unbound.py hook") + _r = _clear_path(config_dir / "hooks" / "unbound.py", "Claude unbound.py hook") if _r == "cleared": any_cleared = True elif _r == "failed": any_failed = True for extra in ( - Path.home() / ".claude" / "hooks" / "unbound-setup.py", - Path.home() / ".claude" / "hooks" / ".last_updated", + config_dir / "hooks" / "unbound-setup.py", + config_dir / "hooks" / ".last_updated", ): _r = _clear_path(extra, str(extra)) if _r == "cleared": @@ -623,7 +641,7 @@ def clear_setup() -> None: elif _r == "failed": any_failed = True - settings_status = remove_hooks_from_settings() + settings_status = remove_hooks_from_settings(config_dir) if settings_status == "cleared": any_cleared = True elif settings_status == "failed": @@ -740,12 +758,13 @@ def get_device_identifier() -> Optional[str]: return None -def detect_install_state() -> str: +def detect_install_state(config_dir: Path = None) -> str: """User-level install state (informational): 'persisted' if this tool's Unbound setup already exists on this device, else 'fresh'. User-level setups are never tamper-eligible, so 'tampered' is never reported.""" + config_dir = config_dir or (Path.home() / ".claude") try: - return "persisted" if (Path.home() / ".claude" / "hooks" / "unbound.py").exists() else "fresh" + return "persisted" if (config_dir / "hooks" / "unbound.py").exists() else "fresh" except Exception as e: debug_print(f"detect_install_state failed: {e}") return "fresh" @@ -943,16 +962,16 @@ def _backfill_upload_chunk(api_key: str, backend_url: str, sessions: List[Dict]) return True -def _backfill_state_path(home: Path) -> Path: - return home / '.claude' / 'hooks' / BACKFILL_STATE_FILE +def _backfill_state_path(config_dir: Path) -> Path: + return config_dir / 'hooks' / BACKFILL_STATE_FILE -def _backfill_read_cutoff(home: Path) -> float: +def _backfill_read_cutoff(config_dir: Path) -> float: """mtime cutoff for transcript selection: the last successful backfill when cached (so cron reruns only seed sessions touched since), else 30 days ago.""" default_cutoff = time.time() - (BACKFILL_MAX_AGE_DAYS * 86400) try: - last = float(_backfill_state_path(home).read_text().strip()) + last = float(_backfill_state_path(config_dir).read_text().strip()) except (OSError, ValueError): return default_cutoff # Ignore corrupt or future timestamps (clock skew). @@ -961,11 +980,11 @@ def _backfill_read_cutoff(home: Path) -> float: return last -def _backfill_write_cutoff(home: Path, ts: float) -> None: +def _backfill_write_cutoff(config_dir: Path, ts: float) -> None: # Write via temp + atomic replace so an overlapping cron run never reads a # half-written timestamp. try: - path = _backfill_state_path(home) + path = _backfill_state_path(config_dir) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.parent / f'{path.name}.{os.getpid()}.tmp' tmp.write_text(str(ts)) @@ -1085,17 +1104,18 @@ def _backfill_slice_session(session: Dict, max_chunk_bytes: int): start_idx = last_fit_end -def run_backfill(api_key: str, backend_url: str) -> None: - """Walk ~/.claude/projects and seed historical sessions. Never raises.""" +def run_backfill(api_key: str, backend_url: str, config_dir: Path = None) -> None: + """Walk config_dir/projects and seed historical sessions. Never raises.""" if os.environ.get('UNBOUND_BACKFILL_DISABLED') == '1': debug_print("UNBOUND_BACKFILL_DISABLED=1 — skipping backfill") return try: - home = Path.home() + if config_dir is None: + config_dir = Path.home() / '.claude' started_at = time.time() - cutoff_mtime = _backfill_read_cutoff(home) - projects_root = home / '.claude' / 'projects' + cutoff_mtime = _backfill_read_cutoff(config_dir) + projects_root = config_dir / 'projects' sessions: List[Dict] = [] capped = False if projects_root.exists(): @@ -1110,7 +1130,7 @@ def run_backfill(api_key: str, backend_url: str) -> None: if session: sessions.append(session) if not sessions: - _backfill_write_cutoff(home, started_at) + _backfill_write_cutoff(config_dir, started_at) print("[backfill] No past sessions found.") return @@ -1157,7 +1177,7 @@ def _flush(): print(f"[backfill] Done — queued {sessions_sent} past sessions ({failed} chunks failed).") else: if not capped: - _backfill_write_cutoff(home, started_at) + _backfill_write_cutoff(config_dir, started_at) print(f"[backfill] Done — queued {sessions_sent} past sessions for processing.") except Exception as e: print(f"[backfill] Skipped due to error: {e}", file=sys.stderr) @@ -1175,8 +1195,10 @@ def main(): DEBUG = True debug_print("Debug mode enabled") + config_dir = _resolve_claude_config_dir(sys.argv) + if clear_mode: - clear_setup() + clear_setup(config_dir) return if check_enterprise_hooks_conflict(): @@ -1248,7 +1270,7 @@ def main(): remove_env_var(var_name) except Exception: pass - remove_gateway_artifacts() + remove_gateway_artifacts(config_dir) debug_print("Setting UNBOUND_CLAUDE_API_KEY environment variable...") success, message = set_env_var("UNBOUND_CLAUDE_API_KEY", api_key) @@ -1257,19 +1279,19 @@ def main(): return debug_print("UNBOUND_CLAUDE_API_KEY set successfully") - _install_state = detect_install_state() + _install_state = detect_install_state(config_dir) _device_id = get_device_identifier() write_unbound_config(api_key, urls={"base_url": backend_url, "gateway_url": gateway_url, "frontend_url": normalize_url(domain) if domain else None}) debug_print("Setting up hooks...") - if not setup_hooks(gateway_url=gateway_url): + if not setup_hooks(gateway_url=gateway_url, config_dir=config_dir): print("āŒ Failed to setup hooks") return debug_print("Hooks downloaded successfully") debug_print("Configuring Claude settings...") - if not configure_claude_settings(): + if not configure_claude_settings(config_dir=config_dir): print("āŒ Failed to configure Claude settings") return debug_print("Claude settings configured successfully") @@ -1281,7 +1303,7 @@ def main(): notify_setup_complete(api_key, "claude-code", backend_url=backend_url, install_state=_install_state, serial_number=_device_id) if backfill_mode: - run_backfill(api_key, backend_url) + run_backfill(api_key, backend_url, config_dir) rc_path = get_shell_rc_file() if rc_path is not None: diff --git a/claude-code/hooks/test_setup.py b/claude-code/hooks/test_setup.py index 34ca382..af98d83 100644 --- a/claude-code/hooks/test_setup.py +++ b/claude-code/hooks/test_setup.py @@ -146,12 +146,13 @@ class TestBackfillCutoffCache(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.home = Path(self._tmp.name) + self.config_dir = self.home / ".claude" self.addCleanup(self._tmp.cleanup) def test_read_cutoff_defaults_to_max_age_when_no_file(self): """No cache file -> fall back to BACKFILL_MAX_AGE_DAYS ago (first run).""" import setup - cutoff = setup._backfill_read_cutoff(self.home) + cutoff = setup._backfill_read_cutoff(self.config_dir) expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) self.assertAlmostEqual(cutoff, expected, delta=5) @@ -159,25 +160,25 @@ def test_write_then_read_roundtrip(self): """A persisted timestamp is read back as the cutoff on the next run.""" import setup ts = time.time() - 3600 - setup._backfill_write_cutoff(self.home, ts) - self.assertTrue(setup._backfill_state_path(self.home).exists()) - self.assertAlmostEqual(setup._backfill_read_cutoff(self.home), ts, delta=0.01) + setup._backfill_write_cutoff(self.config_dir, ts) + self.assertTrue(setup._backfill_state_path(self.config_dir).exists()) + self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), ts, delta=0.01) def test_read_cutoff_ignores_corrupt_value(self): """A non-numeric cache file falls back to the default window.""" import setup - path = setup._backfill_state_path(self.home) + path = setup._backfill_state_path(self.config_dir) path.parent.mkdir(parents=True, exist_ok=True) path.write_text("not-a-number") expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) - self.assertAlmostEqual(setup._backfill_read_cutoff(self.home), expected, delta=5) + self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), expected, delta=5) def test_read_cutoff_ignores_future_timestamp(self): """A future timestamp (clock skew) is rejected for the default window.""" import setup - setup._backfill_write_cutoff(self.home, time.time() + 10000) + setup._backfill_write_cutoff(self.config_dir, time.time() + 10000) expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) - self.assertAlmostEqual(setup._backfill_read_cutoff(self.home), expected, delta=5) + self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), expected, delta=5) def test_iter_transcripts_respects_cutoff(self): """Only transcripts modified at/after the cutoff are yielded.""" @@ -199,8 +200,8 @@ def test_iter_transcripts_respects_cutoff(self): def test_write_is_atomic_and_leaves_no_temp(self): """The atomic write produces the final file and no leftover .tmp.""" import setup - setup._backfill_write_cutoff(self.home, 123.0) - path = setup._backfill_state_path(self.home) + setup._backfill_write_cutoff(self.config_dir, 123.0) + path = setup._backfill_state_path(self.config_dir) self.assertEqual(path.read_text(), "123.0") self.assertEqual(list(path.parent.glob("*.tmp")), []) @@ -208,15 +209,27 @@ def test_cutoff_not_advanced_when_session_cap_fires(self): """When the per-run session cap is hit, the cutoff must NOT advance, or the unprocessed older files would be skipped forever next run.""" import setup - root = self.home / ".claude" / "projects" + root = self.config_dir / "projects" root.mkdir(parents=True) for i in range(3): (root / f"s{i}.jsonl").write_text('{"sessionId":"x%d"}\n' % i) with patch.object(setup, "BACKFILL_MAX_SESSIONS_PER_RUN", 2), \ - patch.object(setup, "_backfill_upload_chunk", return_value=True), \ - patch.object(Path, "home", return_value=self.home): - setup.run_backfill("key", "https://backend") - self.assertFalse(setup._backfill_state_path(self.home).exists()) + patch.object(setup, "_backfill_upload_chunk", return_value=True): + setup.run_backfill("key", "https://backend", self.config_dir) + self.assertFalse(setup._backfill_state_path(self.config_dir).exists()) + + def test_run_backfill_reads_custom_config_dir_projects(self): + """With a custom config_dir, backfill walks config_dir/projects and writes + the cutoff there — not under ~/.claude.""" + import setup + custom = self.home / "custom-cc" + root = custom / "projects" + root.mkdir(parents=True) + (root / "s.jsonl").write_text('{"sessionId":"x"}\n') + with patch.object(setup, "_backfill_upload_chunk", return_value=True): + setup.run_backfill("key", "https://backend", custom) + self.assertTrue(setup._backfill_state_path(custom).exists()) + self.assertFalse(setup._backfill_state_path(self.config_dir).exists()) class TestMdmBackfillCutoff(unittest.TestCase): @@ -359,5 +372,78 @@ def fake_run_as_user(username, fn, *args, **kwargs): ) +class TestResolveClaudeConfigDir(unittest.TestCase): + """WEB-4882: --config-dir arg > CLAUDE_CONFIG_DIR env > ~/.claude.""" + + def test_arg_beats_env_and_home(self): + import setup + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) + self.assertEqual(result, Path("/arg/cc").resolve()) + + def test_env_used_when_no_arg(self): + import setup + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path("/env/cc").resolve()) + + def test_home_default_when_arg_and_env_absent(self): + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path.home() / ".claude") + + def test_relative_value_is_absolutized(self): + import setup + result = setup._resolve_claude_config_dir(["x", "--config-dir", "rel/cc"]) + self.assertEqual(result, Path("rel/cc").resolve()) + + +class TestInstallUnderResolvedDir(unittest.TestCase): + """Hooks + settings + baked command must land under the resolved config dir.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.home = Path(self.tmp) / "home" + self.home.mkdir(parents=True) + self.config_dir = Path(self.tmp) / "custom-cc" + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_settings_and_hook_command_under_config_dir(self): + import setup + with patch.object(Path, "home", staticmethod(lambda: self.home)), \ + patch.object(setup, "download_file", lambda url, dest: dest.parent.mkdir(parents=True, exist_ok=True) or dest.write_text("# hook") or True): + self.assertTrue(setup.setup_hooks(config_dir=self.config_dir)) + self.assertTrue(setup.configure_claude_settings(config_dir=self.config_dir)) + + hook_path = self.config_dir / "hooks" / "unbound.py" + settings_path = self.config_dir / "settings.json" + self.assertTrue(hook_path.exists()) + self.assertTrue(settings_path.exists()) + settings = json.loads(settings_path.read_text()) + cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + self.assertEqual(cmd, str(hook_path)) + self.assertNotIn(str(self.home / ".claude"), cmd) + + def test_backward_compat_no_env_uses_home_claude(self): + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True), \ + patch.object(Path, "home", staticmethod(lambda: self.home)), \ + patch.object(setup, "download_file", lambda url, dest: dest.parent.mkdir(parents=True, exist_ok=True) or dest.write_text("# hook") or True): + config_dir = setup._resolve_claude_config_dir(["x"]) + self.assertTrue(setup.setup_hooks(config_dir=config_dir)) + self.assertTrue(setup.configure_claude_settings(config_dir=config_dir)) + + hook_path = self.home / ".claude" / "hooks" / "unbound.py" + self.assertTrue(hook_path.exists()) + settings = json.loads((self.home / ".claude" / "settings.json").read_text()) + cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + self.assertEqual(cmd, str(hook_path)) + + if __name__ == "__main__": unittest.main() diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 84829d3..5f25ce5 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -17,14 +17,16 @@ UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") -AUDIT_LOG = Path.home() / ".claude" / "hooks" / "agent-audit.log" -ERROR_LOG = Path.home() / ".claude" / "hooks" / "error.log" -LAST_REPORT_FILE = Path.home() / ".claude" / "hooks" / ".last_error_report" +_config_dir_is_default = not (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() +_CONFIG_DIR = Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude")).expanduser().resolve() +AUDIT_LOG = _CONFIG_DIR / "hooks" / "agent-audit.log" +ERROR_LOG = _CONFIG_DIR / "hooks" / "error.log" +LAST_REPORT_FILE = _CONFIG_DIR / "hooks" / ".last_error_report" ALLOWED_NON_MCP_HOOK_NAMES = ['Bash', 'Read', 'Write', 'Edit'] # MCP tools (mcp__*) are always checked separately NATIVE_FILE_TOOLS = {'Read', 'Write', 'Edit'} MCP_TOOL_PREFIX = 'mcp__' -CLAUDE_MCP_CONFIG_PATH = Path.home() / ".claude.json" -POLICY_CACHE_FILE = Path.home() / ".claude" / "hooks" / ".policy_cache.json" +CLAUDE_MCP_CONFIG_PATH = Path.home() / ".claude.json" if _config_dir_is_default else _CONFIG_DIR / ".claude.json" +POLICY_CACHE_FILE = _CONFIG_DIR / "hooks" / ".policy_cache.json" CACHE_TTL_SECONDS = 300 POLICY_CHECK_FAILURE_DEFAULT = 'allow' POLICY_CHECK_FAILURE_BLOCK_REASON = 'policy engine unavailable — please retry' @@ -53,7 +55,7 @@ SELF_UPDATE_INTERVAL_SECONDS = 2 * 3600 SELF_UPDATE_LOCK_TTL_SECONDS = 30 SELF_UPDATE_CURL_TIMEOUT = 10 -SELF_SCRIPT_PATH = Path.home() / ".claude" / "hooks" / "unbound.py" +SELF_SCRIPT_PATH = _CONFIG_DIR / "hooks" / "unbound.py" SELF_UPDATE_STATE_PATH = SELF_SCRIPT_PATH.parent / ".self_update_check" SELF_UPDATE_LOCK_PATH = SELF_SCRIPT_PATH.parent / ".self_update.lock" @@ -238,7 +240,7 @@ def append_to_audit_log(event_data: Dict): pass -_APPROVAL_MARKER_FILE = Path.home() / ".claude" / "hooks" / ".approval_pending" +_APPROVAL_MARKER_FILE = _CONFIG_DIR / "hooks" / ".approval_pending" def _is_approval_retry(command: str) -> bool: From a4d59d4d244c61ffc873a70b55a984d25091b5c3 Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Tue, 23 Jun 2026 15:31:35 +0530 Subject: [PATCH 2/4] WEB-4882: address review feedback - unbound.py: resolve the config dir from the hook's own install location (__file__), so runtime paths always match where the installer wrote them, regardless of how CLAUDE_CONFIG_DIR is propagated into the hook env. - setup.py: strip whitespace-only CLAUDE_CONFIG_DIR, and don't let --config-dir swallow a following flag as its value. Co-Authored-By: Claude Opus 4.8 --- claude-code/hooks/setup.py | 4 ++-- claude-code/hooks/unbound.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 2174b20..705db92 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -64,11 +64,11 @@ def normalize_url(domain: str) -> str: def _resolve_claude_config_dir(argv) -> Path: value = None for i, arg in enumerate(argv): - if arg == "--config-dir" and i + 1 < len(argv): + if arg == "--config-dir" and i + 1 < len(argv) and not argv[i + 1].startswith("--"): value = argv[i + 1] break if not value: - value = os.environ.get("CLAUDE_CONFIG_DIR") + value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None if not value: return Path.home() / ".claude" return Path(value).expanduser().resolve() diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 5f25ce5..4b7f7e3 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -17,8 +17,8 @@ UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") -_config_dir_is_default = not (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() -_CONFIG_DIR = Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude")).expanduser().resolve() +_CONFIG_DIR = Path(__file__).resolve().parents[1] +_config_dir_is_default = _CONFIG_DIR == (Path.home() / ".claude").resolve() AUDIT_LOG = _CONFIG_DIR / "hooks" / "agent-audit.log" ERROR_LOG = _CONFIG_DIR / "hooks" / "error.log" LAST_REPORT_FILE = _CONFIG_DIR / "hooks" / ".last_error_report" From 3cdefe12d971a90aa152fd3082de9d1c46b0b55e Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Tue, 23 Jun 2026 15:40:46 +0530 Subject: [PATCH 3/4] WEB-4882: keep env-based runtime resolution; fix whitespace + .claude.json - unbound.py: resolve _CONFIG_DIR from CLAUDE_CONFIG_DIR (stripped) again, not __file__. Deriving from __file__ made SELF_SCRIPT_PATH always equal the running script, defeating the MDM self-update guard that must skip admin-managed installs. Strip the env value so whitespace-only falls back to ~/.claude consistently for both _CONFIG_DIR and _config_dir_is_default. - unbound.py: CLAUDE_MCP_CONFIG_PATH probes $CONFIG_DIR/.claude.json and falls back to ~/.claude.json, so account-identity reads never break if Claude keeps the OAuth config at the home sibling. - setup.py: strip the --config-dir value too, matching the env handling. Co-Authored-By: Claude Opus 4.8 --- claude-code/hooks/setup.py | 2 +- claude-code/hooks/unbound.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 705db92..092d943 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -65,7 +65,7 @@ def _resolve_claude_config_dir(argv) -> Path: value = None for i, arg in enumerate(argv): if arg == "--config-dir" and i + 1 < len(argv) and not argv[i + 1].startswith("--"): - value = argv[i + 1] + value = argv[i + 1].strip() or None break if not value: value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 4b7f7e3..18e40ba 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -17,15 +17,16 @@ UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") -_CONFIG_DIR = Path(__file__).resolve().parents[1] -_config_dir_is_default = _CONFIG_DIR == (Path.home() / ".claude").resolve() +_env_config_dir = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() +_config_dir_is_default = not _env_config_dir +_CONFIG_DIR = Path(_env_config_dir or (Path.home() / ".claude")).expanduser().resolve() AUDIT_LOG = _CONFIG_DIR / "hooks" / "agent-audit.log" ERROR_LOG = _CONFIG_DIR / "hooks" / "error.log" LAST_REPORT_FILE = _CONFIG_DIR / "hooks" / ".last_error_report" ALLOWED_NON_MCP_HOOK_NAMES = ['Bash', 'Read', 'Write', 'Edit'] # MCP tools (mcp__*) are always checked separately NATIVE_FILE_TOOLS = {'Read', 'Write', 'Edit'} MCP_TOOL_PREFIX = 'mcp__' -CLAUDE_MCP_CONFIG_PATH = Path.home() / ".claude.json" if _config_dir_is_default else _CONFIG_DIR / ".claude.json" +CLAUDE_MCP_CONFIG_PATH = (_CONFIG_DIR / ".claude.json") if (not _config_dir_is_default and (_CONFIG_DIR / ".claude.json").exists()) else (Path.home() / ".claude.json") POLICY_CACHE_FILE = _CONFIG_DIR / "hooks" / ".policy_cache.json" CACHE_TTL_SECONDS = 300 POLICY_CHECK_FAILURE_DEFAULT = 'allow' From aa7f55839b385c102afcdc334bedddd59ab5bec1 Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Tue, 23 Jun 2026 15:51:10 +0530 Subject: [PATCH 4/4] WEB-4882: resolve config dir env-first so install matches runtime Make setup.py prioritize CLAUDE_CONFIG_DIR (env) over --config-dir, with the CLI arg as fallback. unbound.py resolves runtime paths from the same env, so install-time placement and runtime resolution now agree by the same precedence instead of diverging. The CLI passes --config-dir derived from CLAUDE_CONFIG_DIR, so the gated real flow is unchanged. Co-Authored-By: Claude Opus 4.8 --- claude-code/hooks/setup.py | 11 +++++------ claude-code/hooks/test_setup.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 092d943..ea2b28b 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -62,13 +62,12 @@ def normalize_url(domain: str) -> str: def _resolve_claude_config_dir(argv) -> Path: - value = None - for i, arg in enumerate(argv): - if arg == "--config-dir" and i + 1 < len(argv) and not argv[i + 1].startswith("--"): - value = argv[i + 1].strip() or None - break + value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None if not value: - value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None + for i, arg in enumerate(argv): + if arg == "--config-dir" and i + 1 < len(argv) and not argv[i + 1].startswith("--"): + value = argv[i + 1].strip() or None + break if not value: return Path.home() / ".claude" return Path(value).expanduser().resolve() diff --git a/claude-code/hooks/test_setup.py b/claude-code/hooks/test_setup.py index af98d83..c3a2486 100644 --- a/claude-code/hooks/test_setup.py +++ b/claude-code/hooks/test_setup.py @@ -373,12 +373,19 @@ def fake_run_as_user(username, fn, *args, **kwargs): class TestResolveClaudeConfigDir(unittest.TestCase): - """WEB-4882: --config-dir arg > CLAUDE_CONFIG_DIR env > ~/.claude.""" + """WEB-4882: CLAUDE_CONFIG_DIR env > --config-dir arg > ~/.claude.""" - def test_arg_beats_env_and_home(self): + def test_env_beats_arg_and_home(self): import setup with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) + self.assertEqual(result, Path("/env/cc").resolve()) + + def test_arg_used_when_no_env(self): + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) self.assertEqual(result, Path("/arg/cc").resolve()) def test_env_used_when_no_arg(self):