Skip to content

WEB-4882: honor CLAUDE_CONFIG_DIR in Claude Code hooks install#175

Open
MohamedAklamaash wants to merge 4 commits into
mainfrom
aklamaash/web-4882-claude-config-dir
Open

WEB-4882: honor CLAUDE_CONFIG_DIR in Claude Code hooks install#175
MohamedAklamaash wants to merge 4 commits into
mainfrom
aklamaash/web-4882-claude-config-dir

Conversation

@MohamedAklamaash

@MohamedAklamaash MohamedAklamaash commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

WEB-4882 — honor CLAUDE_CONFIG_DIR in Claude Code hooks install

The installer (setup.py) and the runtime hook (unbound.py) hardcoded ~/.claude. With a custom CLAUDE_CONFIG_DIR, hooks were written/read where Claude Code never looks → silent loss of policy enforcement + telemetry.

Changes

  • claude-code/hooks/setup.py: _resolve_claude_config_dir(argv) (precedence --config-dir arg > CLAUDE_CONFIG_DIR env > ~/.claude, expanduser().resolve()); the resolved dir is threaded into hooks dir, settings.json, the baked absolute hook command, uninstall/clear, install-state, and --backfill transcript discovery.
  • claude-code/hooks/unbound.py: module-level _CONFIG_DIR from CLAUDE_CONFIG_DIR at import; audit log, error log, policy cache, approval marker, and self-update target resolve from it. .claude.json uses the custom dir when the env var is set, else the ~/.claude.json sibling.

Notes

  • Backward compatible: with no CLAUDE_CONFIG_DIR, every path equals ~/.claude exactly; the self-update __file__ == SELF_SCRIPT_PATH guard is unaffected for existing installs.
  • ~/.unbound/... paths stay home-anchored (independent of the Claude config dir).
  • Paired with websentry-ai/unbound-cli#WEB-4882 (CLI forwards --config-dir).

Tests

python3 -m pytest test_setup.py -q26 passed. Added precedence, install-under-resolved-dir (asserts the baked command is absolute under the dir), backward-compat, and custom-dir backfill tests.

🤖 Generated with Claude Code


Note

Medium Risk
Changes where policy hooks, logs, and settings are read/written—misaligned CLAUDE_CONFIG_DIR at install vs runtime can silently disable enforcement. The installer does not persist CLAUDE_CONFIG_DIR to the shell profile, so --config-dir-only installs may not match future Claude sessions unless the env is set elsewhere.

Overview
Fixes WEB-4882 by stopping hardcoded ~/.claude paths when Claude Code uses a custom config directory.

setup.py adds _resolve_claude_config_dir (CLAUDE_CONFIG_DIR--config-dir~/.claude, with expanduser().resolve()). main() resolves once and passes that config_dir through install, --clear, gateway artifact cleanup, install-state detection, and --backfill (transcripts under config_dir/projects, cutoff state under config_dir/hooks).

unbound.py sets _CONFIG_DIR from CLAUDE_CONFIG_DIR at import so audit/error logs, policy cache, approval markers, and self-update target the same tree. MCP config prefers config_dir/.claude.json when the env is set and the file exists; otherwise it keeps ~/.claude.json. ~/.unbound/* stays home-based.

Tests cover resolution precedence, hooks/settings with baked absolute commands under a custom dir, default-dir backward compatibility, and custom-dir backfill.

Reviewed by Cursor Bugbot for commit aa7f558. Bugbot is set up for automated code reviews on this repo. Configure here.

Greptile Summary

This PR fixes silent policy-enforcement and telemetry loss by honoring CLAUDE_CONFIG_DIR (and a new --config-dir CLI flag) throughout the installer and runtime hook, replacing all hardcoded ~/.claude paths with a resolved config root.

  • setup.py: Adds _resolve_claude_config_dir and threads the resolved config_dir through hook download, settings.json baked commands, clear/uninstall, install-state detection, and backfill discovery.
  • unbound.py: Resolves _CONFIG_DIR from CLAUDE_CONFIG_DIR at import time so audit/error logs, policy cache, approval markers, and the self-update target all live under the custom config tree.
  • test_setup.py: Updates existing backfill helpers to accept config_dir and adds coverage for precedence, install paths, backward compatibility, and custom-dir backfill.

Confidence Score: 4/5

Safe to merge after fixing the --config-dir / CLAUDE_CONFIG_DIR precedence order in _resolve_claude_config_dir.

The resolver checks the environment variable before the CLI flag, so any user who has CLAUDE_CONFIG_DIR in their shell and passes an explicit --config-dir to override it will have their flag silently ignored. Every other change — threading config_dir through all install paths, the unbound.py module-level resolution, backward-compatible defaults — looks correct and well-tested.

claude-code/hooks/setup.py (_resolve_claude_config_dir precedence logic) and claude-code/hooks/test_setup.py (test_env_beats_arg_and_home codifies the wrong order).

Important Files Changed

Filename Overview
claude-code/hooks/setup.py Adds _resolve_claude_config_dir and threads config_dir through all install/uninstall paths correctly, but the resolution precedence is inverted: env var wins over explicit --config-dir arg, contrary to both the PR description and standard CLI convention.
claude-code/hooks/unbound.py Resolves _CONFIG_DIR from CLAUDE_CONFIG_DIR at import with correct whitespace handling; all runtime paths (audit log, policy cache, approval marker, self-update) moved to config dir. CLAUDE_MCP_CONFIG_PATH fallback logic is correct but already discussed in prior threads.
claude-code/hooks/test_setup.py Adds resolution-precedence, install-path, backward-compat, and custom-dir backfill tests; the test_env_beats_arg_and_home test inadvertently codifies the inverted precedence behaviour.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[setup.py main] --> B[_resolve_claude_config_dir]
    B --> C{CLAUDE_CONFIG_DIR set?}
    C -- Yes --> D[use env var path]
    C -- No --> E{--config-dir arg present?}
    E -- Yes --> F[use arg path]
    E -- No --> G[use ~/.claude default]
    D --> H[config_dir: Path]
    F --> H
    G --> H
    H --> I[setup_hooks config_dir]
    H --> J[configure_claude_settings config_dir]
    H --> K[detect_install_state config_dir]
    H --> L[remove_gateway_artifacts config_dir]
    H --> M[run_backfill config_dir]
    H --> N[clear_setup config_dir]
    I --> O[config_dir/hooks/unbound.py]
    J --> P[config_dir/settings.json baked hook command]
    M --> Q[config_dir/projects/ config_dir/hooks/backfill-state]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[setup.py main] --> B[_resolve_claude_config_dir]
    B --> C{CLAUDE_CONFIG_DIR set?}
    C -- Yes --> D[use env var path]
    C -- No --> E{--config-dir arg present?}
    E -- Yes --> F[use arg path]
    E -- No --> G[use ~/.claude default]
    D --> H[config_dir: Path]
    F --> H
    G --> H
    H --> I[setup_hooks config_dir]
    H --> J[configure_claude_settings config_dir]
    H --> K[detect_install_state config_dir]
    H --> L[remove_gateway_artifacts config_dir]
    H --> M[run_backfill config_dir]
    H --> N[clear_setup config_dir]
    I --> O[config_dir/hooks/unbound.py]
    J --> P[config_dir/settings.json baked hook command]
    M --> Q[config_dir/projects/ config_dir/hooks/backfill-state]
Loading

Reviews (4): Last reviewed commit: "WEB-4882: resolve config dir env-first s..." | Re-trigger Greptile

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 <noreply@anthropic.com>
@MohamedAklamaash MohamedAklamaash requested a review from a team June 23, 2026 09:42
Comment thread claude-code/hooks/setup.py
Comment thread claude-code/hooks/unbound.py Outdated
Comment thread claude-code/hooks/setup.py Outdated
Comment thread claude-code/hooks/unbound.py Outdated
Comment thread claude-code/hooks/unbound.py Outdated
Comment thread claude-code/hooks/setup.py
@vigneshsubbiah16

Copy link
Copy Markdown
Collaborator

🛡️ Automated Security Review (consensus)

3 findings — 3 high-confidence, 0 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.

🔴 HIGH — Installer/runtime config-dir resolution mismatch

claude-code/hooks/unbound.py:19-21 (also claude-code/hooks/setup.py:63-74)

Impact: setup.py honors --config-dir > CLAUDE_CONFIG_DIR > ~/.claude, but the runtime hook resolves _CONFIG_DIR only from CLAUDE_CONFIG_DIR; installing with --config-dir (no env at hook runtime) writes hooks/settings under dir X while audit log, policy cache, approval marker, and self-update paths read/write under ~/.claude — silent loss of policy enforcement and telemetry on the exact path this PR targets.

Fix: Bake the resolved config dir into the hook invocation (e.g. pass --config-dir in the baked settings.json command) or replicate the same --config-dir > env > home precedence in unbound.py.

Flagged by: Cursor, Claude


🟡 MEDIUM — Whitespace-only CLAUDE_CONFIG_DIR splits path logic

claude-code/hooks/unbound.py:19-28

Impact: _config_dir_is_default uses .strip(), so a whitespace-only env routes CLAUDE_MCP_CONFIG_PATH to ~/.claude.json, but _CONFIG_DIR keeps the raw truthy whitespace value — audit, policy, and approval paths land in a bogus directory while MCP identity reads home.

Fix: Normalize once: raw = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip(); derive both _CONFIG_DIR and the MCP path from that single value.

Flagged by: Cursor, Claude


🟡 LOW — --config-dir consumes the next argv token without validation

claude-code/hooks/setup.py:65-69

Impact: If the token after --config-dir is another flag (e.g. --config-dir --clear), that flag string becomes the config directory — install/clear/backfill silently target the wrong location.

Fix: Reject or ignore a next token that starts with - (treat as a missing value and fall through to env/default).

Flagged by: Cursor, Claude


Note: Semgrep reported file-permission and test urllib hits; these are pre-existing patterns (restrictive 0o700/0o755 dirs and test-only HTTP mocks) and were not elevated — Gitleaks found no secrets.


🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 76a92523 · 2026-06-23T09:47Z

- 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 <noreply@anthropic.com>
@MohamedAklamaash

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in a4d59d4:

  • [High] Install arg / env runtime mismatch (unbound.py) — fixed. _CONFIG_DIR now resolves from the hook's own install location: Path(__file__).resolve().parents[1]. Claude loads the hook from $CLAUDE_CONFIG_DIR/hooks/unbound.py, so the hook's runtime paths (audit log, error log, policy cache, approval marker, self-update target, .claude.json) always match the directory the installer wrote them to — regardless of whether/how CLAUDE_CONFIG_DIR is propagated into the hook subprocess. This also removes the install-time (--config-dir/env) vs runtime divergence entirely. The self-update guard (__file__ == SELF_SCRIPT_PATH) still holds, and an existing ~/.claude install resolves byte-identically.
  • [Medium] Whitespace env splits path logic — fixed. unbound.py no longer reads the env for _CONFIG_DIR at all (it uses __file__), and _config_dir_is_default is derived from _CONFIG_DIR == ~/.claude, so .claude.json selection is consistent. setup.py's _resolve_claude_config_dir now does (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None, so a whitespace-only value falls back to ~/.claude.
  • [Low] Config dir consumes next flag (setup.py) — fixed. --config-dir only takes the next token as its value when it doesn't start with --, so --config-dir --clear no longer treats --clear as the path.
  • [Greptile] .claude.json location — deliberate: when the config dir is non-default we use $CONFIG_DIR/.claude.json, else the ~/.claude.json sibling. Claude Code's docs don't pin the relocated path, so this is the best-known behavior; worth a quick empirical probe (CLAUDE_CONFIG_DIR=/tmp/cc-test claude) before relying on it broadly. Tracked in the WEB-4882 notes.

pytest test_setup.py → 26 passed.

Comment thread claude-code/hooks/unbound.py Outdated
Comment thread claude-code/hooks/unbound.py
Comment thread claude-code/hooks/setup.py
….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 <noreply@anthropic.com>
@MohamedAklamaash

Copy link
Copy Markdown
Contributor Author

Thanks — the re-review caught that my __file__-based resolution was the wrong fix. Corrected in 3cdefe1:

  • [High] MDM self-update guard bypassed — reverted. _CONFIG_DIR resolves from CLAUDE_CONFIG_DIR (stripped) again, so SELF_SCRIPT_PATH is the user-level path and the __file__ != SELF_SCRIPT_PATH guard once more skips self-update for admin-managed installs. (Deriving from __file__ is exactly what broke it — good catch.)
  • [High] Wrong global OAuth config path — fixed. CLAUDE_MCP_CONFIG_PATH now probes $CONFIG_DIR/.claude.json and falls back to ~/.claude.json, so read_account_identity / MCP enrichment still find the home-sibling OAuth config if Claude keeps it there under a relocated dir.
  • [Medium] Whitespace env splits path logic — fixed. Both _config_dir_is_default and _CONFIG_DIR derive from the same stripped CLAUDE_CONFIG_DIR, so a whitespace-only value falls back to ~/.claude consistently.
  • [Low] Whitespace --config-dir not stripped (setup.py) — fixed: argv[i+1].strip() or None, matching the env handling.

On the original [High] install-arg / runtime-env mismatch: in the real flow this can't diverge — the CLI computes --config-dir from CLAUDE_CONFIG_DIR, and at runtime Claude invokes the hook with that same CLAUDE_CONFIG_DIR in env, so install-time placement and runtime resolution agree by construction. Resolving the runtime dir from env (not __file__) is required to keep the MDM self-update guard intact, so env-based is the correct trade-off; the only way to diverge is hand-invoking setup.py --config-dir X with a different env, which isn't a real path.

pytest test_setup.py → 26 passed.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hook config dir ignores CLI arg

High Severity

setup.py honors --config-dir over CLAUDE_CONFIG_DIR, but unbound.py sets _CONFIG_DIR only from the env var or ~/.claude. After install with --config-dir alone, Claude still runs the baked hook under that directory while audit logs, policy cache, approval markers, and related paths resolve under the default config tree—breaking the end-to-end custom config path this change targets.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3cdefe1. Configure here.

@vigneshsubbiah16

Copy link
Copy Markdown
Collaborator

🛡️ Automated Security Review (consensus)

0 findings — 0 high-confidence, 0 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.

✅ Security consensus: no issues found. (reviewers: Cursor, Claude, Semgrep, Gitleaks)

Previously acknowledged (not re-flagged)

  • Install --config-dir vs runtime CLAUDE_CONFIG_DIR mismatch — Maintainer: CLI derives --config-dir from env; Claude invokes the hook with the same CLAUDE_CONFIG_DIR; divergence only on hand-invoked setup, not a real deployment path.
  • MDM self-update guard / __file__-based _CONFIG_DIR — Maintainer: reverted to env-based resolution in 3cdefe1 so SELF_SCRIPT_PATH stays user-level and admin-managed installs skip self-update again.
  • .claude.json / CLAUDE_MCP_CONFIG_PATH location — Maintainer: deliberate probe of $CONFIG_DIR/.claude.json with fallback to ~/.claude.json; tracked in WEB-4882 notes.
  • Whitespace-only CLAUDE_CONFIG_DIR / --config-dir — Maintainer: fixed via .strip() in both setup.py and unbound.py (3cdefe1).
  • --config-dir consuming the next flag — Maintainer: fixed; next token ignored when it starts with --.
  • Semgrep insecure-file-permissions on 0o700/0o755 — Pre-existing hook dir modes; 0o700 is owner-only (rule misfire vs 0o644), not introduced or widened by this diff.
  • Semgrep dynamic urllib in test_setup.py — Test harness only; not a production attack surface.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 3cdefe12 · 2026-06-23T10:15Z

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 <noreply@anthropic.com>
@MohamedAklamaash

Copy link
Copy Markdown
Contributor Author

[High] Hook config dir ignores CLI arg — fixed in aa7f558. _resolve_claude_config_dir now resolves CLAUDE_CONFIG_DIR (env) first, with --config-dir only as a fallback. unbound.py already resolves its runtime paths from CLAUDE_CONFIG_DIR, so install-time and runtime now follow the same precedence and can't diverge. The CLI derives --config-dir from CLAUDE_CONFIG_DIR and the curl→python child inherits that env, so the normal flow is unchanged; the arg just covers the case where the env isn't propagated. Precedence test updated (test_env_beats_arg_and_home + test_arg_used_when_no_env); pytest test_setup.py → 27 passed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit aa7f558. Configure here.


if clear_mode:
clear_setup()
clear_setup(config_dir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clear misses custom config directory

High Severity

Install can target a custom Claude config tree via --config-dir, but the installer never persists that path (for example as CLAUDE_CONFIG_DIR). A later setup.py --clear or cron --backfill resolves the config directory only from the environment or default ~/.claude, so hooks and settings under the custom directory can remain while clear appears to succeed.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit aa7f558. Configure here.

@vigneshsubbiah16

Copy link
Copy Markdown
Collaborator

🛡️ Automated Security Review (consensus)

0 findings — 0 high-confidence, 0 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.

✅ Security consensus: no issues found. (reviewers: Cursor, Claude, Semgrep, Gitleaks)

Previously acknowledged (not re-flagged)

  • Install-arg vs runtime-env mismatch — Maintainer: env-first precedence in aa7f558 aligns setup.py with unbound.py; normal CLI/curl flow propagates CLAUDE_CONFIG_DIR, so install and runtime agree by construction.
  • MDM self-update guard bypass__file__-based _CONFIG_DIR reverted in 3cdefe1; env-based resolution restores SELF_SCRIPT_PATH guard for admin-managed installs.
  • .claude.json / CLAUDE_MCP_CONFIG_PATH under custom dir — Accepted by design: probe $CONFIG_DIR/.claude.json, fallback to ~/.claude.json; empirical confirmation tracked in WEB-4882 notes.
  • Whitespace-only CLAUDE_CONFIG_DIR / --config-dir and --config-dir consuming the next flag — Fixed in maintainer commits (strip() + guarded token check).
  • --config-dir-only install without persisted env (--clear / backfill miss) — Operational robustness gap, not an exploitable vuln; maintainer notes real flow inherits env from CLI; hand-invoked mismatched install is out of scope.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head aa7f5583 · 2026-06-23T10:28Z

@MohamedAklamaash

Copy link
Copy Markdown
Contributor Author

[High] Clear misses custom config directory — by design, with the same precedence as install. --clear and --backfill resolve the config dir exactly like install does: CLAUDE_CONFIG_DIR (env) first, then --config-dir, then ~/.claude. Because CLAUDE_CONFIG_DIR is the same mechanism Claude Code itself uses to locate a profile, it must be present to operate on that profile — clearing a custom-dir install requires the same env that made Claude use it, which is normally set persistently in the user's shell profile. So in the standard flow clear targets the right tree.

The narrow gap is purely an ad-hoc-env one (set CLAUDE_CONFIG_DIR only for the install command, then clear later in a shell without it). Closing that fully means persisting the install path in home-anchored state (e.g. ~/.unbound/config.json) and reading it back on clear/backfill — happy to do that as a follow-up if you'd like the extra robustness, but it adds cross-invocation state beyond WEB-4882's scope.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants