Skip to content

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

Open
MohamedAklamaash wants to merge 12 commits into
stagingfrom
aklamaash/web-4882-claude-config-dir
Open

WEB-4882: honor CLAUDE_CONFIG_DIR in Claude Code hooks install#175
MohamedAklamaash wants to merge 12 commits into
stagingfrom
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 and API-key helpers are written and read; default ~/.claude behavior is preserved when the env var is unset, but mis-resolution could still break enforcement for relocated installs.

Overview
Fixes silent policy/telemetry gaps when Claude Code uses a custom config directory by routing all Unbound install and runtime artifacts through a resolved config dir instead of hardcoded ~/.claude.

Gateway and hooks installers add _resolve_claude_config_dir (CLAUDE_CONFIG_DIR--config-dir → default), thread that path through hook/key-helper writes, settings.json, install-state, --backfill transcript discovery, and --clear. Clear also strips leftover enforcement under ~/.claude when the active dir is relocated. Gateway keeps portable ~/.claude/anthropic_key.sh in apiKeyHelper only for the default dir; custom dirs get an absolute helper path.

unbound.py resolves _CONFIG_DIR from CLAUDE_CONFIG_DIR at import (logs, policy cache, approval marker, self-update target) and uses _relocated_or_legacy for .claude.json / plugin cache when Claude’s layout varies by version.

New and updated unit tests cover resolver precedence, install paths, legacy sweep on clear, and custom-dir backfill.

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

Greptile Summary

This PR fixes silent loss of Unbound policy enforcement when Claude Code uses a non-default config directory (CLAUDE_CONFIG_DIR) by routing all installer and runtime hook paths through a resolved config dir instead of the hardcoded ~/.claude. The change is backward compatible: with no CLAUDE_CONFIG_DIR set every path equals ~/.claude exactly.

  • hooks/setup.py and gateway/setup.py: new _resolve_claude_config_dir (env > arg > ~/.claude) threads config_dir through hook install, settings.json, apiKeyHelper, --clear, install-state detection, and --backfill transcript discovery.
  • hooks/unbound.py: module-level _CONFIG_DIR from CLAUDE_CONFIG_DIR at import drives all runtime paths; _relocated_or_legacy defers .claude.json / plugin-cache location to an existence check.
  • Tests: new suites cover resolution precedence, custom-dir install, baked-command correctness, backward compat, and legacy-sweep on clear.

Confidence Score: 5/5

Safe to merge — behavior is unchanged when CLAUDE_CONFIG_DIR is unset, and the custom-dir path is exercised by the new tests.

All changed code paths are additive (new config-dir resolution layered on top of unchanged defaults), tests verify both the custom-dir and backward-compat cases, and there are no data-loss or policy-enforcement regressions on the default install path.

claude-code/hooks/setup.py — the manual argv parser in _resolve_claude_config_dir silently ignores the --config-dir=value form.

Important Files Changed

Filename Overview
claude-code/hooks/setup.py Adds _resolve_claude_config_dir (env wins over --config-dir arg), threads config_dir through setup_hooks, configure_claude_settings, clear_setup, detect_install_state, run_backfill, and related helpers.
claude-code/hooks/unbound.py Module-level _CONFIG_DIR from CLAUDE_CONFIG_DIR drives all runtime paths; _relocated_or_legacy guards version-dependent .claude.json / plugin-cache placement; self-update guard correctly resolves both file and SELF_SCRIPT_PATH.
claude-code/gateway/setup.py Mirrors hooks pattern with _resolve_claude_config_dir for key-helper, settings.json, clear_setup, and detect_install_state; clear_setup sweeps legacy ~/.claude when config_dir is relocated.
claude-code/hooks/test_setup.py Updates backfill tests to pass config_dir directly; adds TestResolveClaudeConfigDir and TestInstallUnderResolvedDir.
claude-code/gateway/test_setup.py New test file covering _resolve_claude_config_dir precedence, setup_claude_key_helper, detect_install_state, and legacy-sweep on clear.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A([Start: resolve config dir]) --> B{CLAUDE_CONFIG_DIR set and non-blank?}
    B -- Yes --> C[Use CLAUDE_CONFIG_DIR .expanduser.resolve]
    B -- No --> D{--config-dir arg provided?}
    D -- Yes --> E[Use --config-dir value .expanduser.resolve]
    D -- No --> F[Default: ~/.claude]
    C --> G([Resolved config_dir])
    E --> G
    F --> G
    G --> H[hooks dir = config_dir/hooks]
    G --> I[settings.json = config_dir/settings.json]
    G --> J[unbound.py = config_dir/hooks/unbound.py]
    H & I & J --> L([Install hooks + settings])
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([Start: resolve config dir]) --> B{CLAUDE_CONFIG_DIR set and non-blank?}
    B -- Yes --> C[Use CLAUDE_CONFIG_DIR .expanduser.resolve]
    B -- No --> D{--config-dir arg provided?}
    D -- Yes --> E[Use --config-dir value .expanduser.resolve]
    D -- No --> F[Default: ~/.claude]
    C --> G([Resolved config_dir])
    E --> G
    F --> G
    G --> H[hooks dir = config_dir/hooks]
    G --> I[settings.json = config_dir/settings.json]
    G --> J[unbound.py = config_dir/hooks/unbound.py]
    H & I & J --> L([Install hooks + settings])
Loading

Reviews (12): Last reviewed commit: "Merge remote-tracking branch 'origin/sta..." | 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.

Comment thread claude-code/hooks/unbound.py
@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.

Comment thread claude-code/hooks/setup.py Outdated
@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.

…aude-config-dir

# Conflicts:
#	claude-code/hooks/setup.py
#	claude-code/hooks/test_setup.py
#	claude-code/hooks/unbound.py
Comment thread claude-code/hooks/test_setup.py
Comment on lines +65 to +74
def _resolve_claude_config_dir(argv) -> Path:
value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None
if not value:
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Env-var beats CLI arg — precedence is inverted from stated intent

_resolve_claude_config_dir checks CLAUDE_CONFIG_DIR first, then falls through to --config-dir. The "Changes" section of the PR description explicitly states the intended order is --config-dir arg > CLAUDE_CONFIG_DIR env > ~/.claude. The implementation (and its test test_env_beats_arg_and_home) does the opposite. With this order, any user who has CLAUDE_CONFIG_DIR set in their shell profile cannot override the installation target via --config-dir, which is the standard CLI convention.

Suggested change
def _resolve_claude_config_dir(argv) -> Path:
value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None
if not value:
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()
def _resolve_claude_config_dir(argv) -> Path:
# CLI arg is the most specific — check it first.
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
if value:
return Path(value).expanduser().resolve()
break
# Fall back to environment variable.
value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None
if value:
return Path(value).expanduser().resolve()
return Path.home() / ".claude"

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ 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 CLAUDE_CONFIG_DIR / runtime mismatch — env-first precedence is deliberate; CLI and Claude runtime both derive from CLAUDE_CONFIG_DIR, so install and hook paths align in the normal flow.
  • MDM self-update guard__file__-based _CONFIG_DIR was reverted; env-based resolution restores the managed-install skip for __file__ != SELF_SCRIPT_PATH.
  • .claude.json location under custom config dir — intentional: probe $CONFIG_DIR/.claude.json, fall back to ~/.claude.json.
  • --clear / --backfill without ad-hoc env — accepted by design; same resolution as install; persistent CLAUDE_CONFIG_DIR is required (follow-up offered for home-anchored state).
  • Whitespace-only CLAUDE_CONFIG_DIR / --config-dir, flag-as-value parsing — fixed via .strip() and not argv[i+1].startswith("--").
  • Semgrep insecure-file-permissions (0o700/0o755) — pre-existing chmod patterns; 0o700 is owner-only (stricter than 0o644); not introduced by this diff.
  • Semgrep dynamic-urllib-use in tests — test-only mock URL fetch; out of scope for this path-threading change.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head d74ba52a · 2026-06-26T06:11Z

@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/runtime config-dir mismatch — Maintainer: in the normal flow the CLI derives --config-dir from CLAUDE_CONFIG_DIR and Claude invokes the hook with the same env; env-based _CONFIG_DIR is required to preserve the MDM self-update guard (__file__ != SELF_SCRIPT_PATH).
  • .claude.json location under custom config dir — Deliberate: probe $CONFIG_DIR/.claude.json, fall back to ~/.claude.json; best-known behavior pending empirical confirmation (WEB-4882 notes).
  • MDM self-update guard__file__-based _CONFIG_DIR was reverted; env-derived SELF_SCRIPT_PATH restores the managed-install skip.
  • --clear / --backfill without ad-hoc env — By design: same precedence as install (CLAUDE_CONFIG_DIR--config-dir~/.claude); clearing a custom profile requires the same env Claude uses; persistent home-anchored state deferred as follow-up.
  • CLAUDE_CONFIG_DIR beats --config-dir — Intentional (aa7f558) so install-time resolution matches unbound.py runtime (env-only); CLI forwards env into the installer child.
  • Whitespace env/arg, --config-dir consuming next flag — Fixed in prior commits (3cdefe1, aa7f558).

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head fd73b342 · 2026-06-29T05:31Z

Comment thread claude-code/hooks/setup.py
Mirror the hooks installer: resolve the config dir from CLAUDE_CONFIG_DIR / the
--config-dir arg the CLI forwards (else ~/.claude), and thread it through the
key-helper writer, settings, install-state detection, and clear. apiKeyHelper
keeps the portable ~/.claude form for the default dir and uses the absolute path
when relocated so Claude resolves it under the active dir. Adds gateway test_setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread claude-code/hooks/unbound.py Outdated
@vigneshsubbiah16

Copy link
Copy Markdown
Collaborator

🛡️ Automated Security Review (consensus)

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

🟡 TRIAGE — Symlinked config dir breaks hook detection on clear/reinstall

claude-code/hooks/setup.py:408

  • Impact: _resolve_claude_config_dir now returns .resolve()’d paths, but _command_targets_hook compares with normpath only; if ~/.claude (or the configured dir) is a symlink, --clear may fail to match/remove baked hook commands and reinstall can append duplicates—policy hooks can remain after a user believes they cleared Unbound.
  • Fix: Normalize both sides the same way in _command_targets_hook (e.g. Path(...).resolve() or os.path.samefile) so detection matches the resolved paths written at install time.
  • Reviewers: Cursor

Previously acknowledged (not re-flagged)

  • Install/runtime config-dir mismatch — Accepted: install and runtime both use CLAUDE_CONFIG_DIR with the same env-first precedence; divergence only on hand-invoked setup.py --config-dir without the env, which is not the real Claude Code flow.
  • MDM self-update guard / SELF_SCRIPT_PATH — Fixed: reverted __file__-based resolution; env-based _CONFIG_DIR preserves the __file__ != SELF_SCRIPT_PATH skip for admin-managed installs.
  • CLAUDE_MCP_CONFIG_PATH / .claude.json location — Accepted by design: prefer $CONFIG_DIR/.claude.json when present, else fall back to ~/.claude.json; empirical probe of Claude Code’s relocated path tracked in WEB-4882 notes.
  • Import-time MCP path on first custom-dir run — Covered by the deliberate fallback above; not re-flagged.
  • Env-over---config-dir precedence — Accepted by design: env-first aligns setup.py with unbound.py runtime and matches how Claude Code locates the profile (aa7f558).
  • --clear / --backfill without persisted custom dir — Accepted by design: same resolution as install; operating on a relocated profile requires the same CLAUDE_CONFIG_DIR Claude uses (cross-invocation state deferred).
  • Whitespace-only CLAUDE_CONFIG_DIR / --config-dir — Fixed via .strip() in both setup.py and unbound.py.
  • --config-dir consuming the next flag — Fixed: next token ignored when it starts with --.
  • Semgrep insecure-file-permissions (0o700/0o755) — Pre-existing chmod patterns unchanged by this diff; owner-only dirs are intentional for local install artifacts, not a new exposure.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 9c58392a · 2026-06-29T09:22Z

Strip WEB-4882 references and redundant inline comments; behavior unchanged.
@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-time vs runtime config-dir mismatch — Maintainer aligned CLAUDE_CONFIG_DIR (env) precedence in both setup.py and unbound.py; ad-hoc --config-dir-only divergence is out of scope for WEB-4882.
  • MDM self-update guard bypass (__file__-based _CONFIG_DIR) — Reverted to env-based resolution so __file__ != SELF_SCRIPT_PATH still skips self-update on admin-managed installs.
  • Whitespace-only CLAUDE_CONFIG_DIR / --config-dir — Stripped in both installer and runtime; blank values fall back to ~/.claude.
  • --config-dir consuming the next flag — Fixed with a ---prefix guard on the following token.
  • .claude.json location / home-sibling fallback — Deliberate: probe $CONFIG_DIR/.claude.json for non-default dirs, else ~/.claude.json; accepted pending empirical confirmation of Claude Code’s relocated path.
  • --clear / --backfill without persisted install path — By design: same resolver as install; operating on a custom profile requires the same CLAUDE_CONFIG_DIR Claude uses.
  • CLAUDE_CONFIG_DIR env beats --config-dir arg — Intentional to keep install and runtime precedence identical (CLI forwards env-derived --config-dir).
  • Symlinked ~/.claude vs resolved paths in _command_targets_hook — Install/clear correctness only (missed clear or duplicate hooks leaves enforcement on); not treated as a security regression.
  • Semgrep insecure-file-permissions on 0o700/0o755 — Pre-existing chmod patterns on user-owned config trees; modes are owner-restrictive, not newly introduced exposure.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 71363889 · 2026-06-29T09:52Z

…lear sweep, portable apiKeyHelper

- unbound.py: resolve .claude.json AND plugins/cache the same way — prefer the
  relocated dir when it has the artifact, else the legacy ~/.claude location —
  so MCP/plugin policy reads from wherever Claude actually stores them.
- setup --clear (hooks + gateway): when the config dir is relocated, also strip
  enforcement left behind in the default ~/.claude so nothing fires if Claude
  later runs without CLAUDE_CONFIG_DIR.
- gateway apiKeyHelper: compare resolved paths so the portable ~/.claude form is
  kept even on symlinked HOME / when CLAUDE_CONFIG_DIR equals the default dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ 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)

  • .claude.json / MCP config path at import (unbound.py:29) — maintainer: deliberate _relocated_or_legacy probe ($CONFIG_DIR/.claude.json then ~/.claude.json); residual wrong-account risk if the relocated file is absent at import is an accepted design choice pending empirical validation of Claude Code’s relocated layout.
  • --clear / --backfill without CLAUDE_CONFIG_DIR (setup.py) — maintainer: by design; custom-dir operations require the same env Claude uses for that profile; ad-hoc-env-only installs are a known narrow gap, with persisted state deferred as follow-up.
  • Install vs runtime config-dir precedence (env > --config-dir) — maintainer: intentional so installer and unbound.py share one resolution order; CLI forwards env in the normal flow; hand-invoking setup.py with a mismatched env is out of scope.
  • Semgrep insecure-file-permissions / test urllib hits — pre-existing installer patterns (0o700/0o755 for hook dirs and executables); not introduced or worsened by this path-resolution change.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 870240e6 · 2026-06-29T12:31Z

Comment thread claude-code/hooks/setup.py
Comment thread claude-code/hooks/setup.py
@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 — env-first precedence in both setup.py and unbound.py; maintainer: consistent-by-construction in the real CLI/Claude flow (CLI derives arg from env; hook subprocess inherits env).
  • MDM self-update guard bypass (__file__-based _CONFIG_DIR) — reverted; _CONFIG_DIR/SELF_SCRIPT_PATH again env-derived so __file__ != SELF_SCRIPT_PATH skips self-update on admin-managed installs.
  • .claude.json / CLAUDE_MCP_CONFIG_PATH under relocated config — deliberate _relocated_or_legacy probe with ~/.claude.json fallback; version-dependent Claude behavior tracked in WEB-4882.
  • --clear / --backfill without ad-hoc install env — accepted by design; persistent CLAUDE_CONFIG_DIR is the supported contract; home-anchored install-state persistence offered as follow-up.
  • Whitespace-only config dir, --config-dir consuming next flag, symlink hook-path matching — fixed or non-security operational edge cases per maintainer review cycle.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 00b45333 · 2026-07-01T13:54Z

# Conflicts:
#	claude-code/hooks/unbound.py

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

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

🔴 HIGH — Unquoted baked hook / apiKeyHelper paths break on spaces or shell metacharacters

claude-code/hooks/setup.py:429, claude-code/gateway/setup.py:343
Impact: For a custom config dir, the baked hook command and apiKeyHelper are written as raw str(path) on POSIX (Windows quotes only); a dir like /home/user/my configs/cc word-splits at shell launch and Claude Code fails open on hook errors — silent loss of PreToolUse policy enforcement and telemetry.
Fix: Wrap baked paths with shlex.quote() on all platforms (mirror the Windows branch), and update _command_targets_hook matching and test assertions accordingly.
Reviewers: Claude

🔴 HIGH — --config-dir=/path (equals form) silently ignored in hooks installer

claude-code/hooks/setup.py:69
Impact: The manual argv scan only handles --config-dir <value> as two tokens; --config-dir=/path falls through to env/default, so install/clear/backfill can target ~/.claude while Claude runs a custom dir — the same silent policy/telemetry loss WEB-4882 fixes (gateway's argparse path already accepts the equals form).
Fix: Also match arg.startswith("--config-dir=") and split on the first =.
Reviewers: Cursor, Claude

🟡 TRIAGE — Legacy ~/.claude sweep on clear omits auxiliary hook artifacts

claude-code/hooks/setup.py:674
Impact: When clearing a relocated config dir, the legacy sweep removes unbound.py and settings hook entries under ~/.claude but not unbound-setup.py or .last_updated, leaving stale files that could confuse reinstall or state detection.
Fix: Extend the legacy sweep to delete the same extras cleared on the primary config dir.
Reviewers: Cursor

Previously acknowledged (not re-flagged)

  • Install/clear without CLAUDE_CONFIG_DIR in a later shell — by design: clear/backfill use the same env-first resolution as install; persistent env is the supported contract; home-anchored state is a possible follow-up.
  • .claude.json / OAuth identity via _relocated_or_legacy fallback to ~/.claude.json — deliberate design choice pending empirical confirmation of Claude Code's relocation behavior.
  • Install-time vs runtime config-dir precedence / env-over-arg — intentional (CLAUDE_CONFIG_DIR first so install and unbound.py agree; CLI derives --config-dir from env in the normal flow).
  • Symlinked config dir breaks _command_targets_hook matching on clear — prior consensus triaged to no-issue.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 1f0f74b9 · 2026-07-02T16:04Z

@MohamedAklamaash MohamedAklamaash changed the base branch from main to staging July 3, 2026 07:14
# Conflicts:
#	claude-code/gateway/setup.py
#	claude-code/hooks/setup.py

@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.

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 d83b1b2. Configure here.

CLAUDE_PLUGIN_CACHE_DIR = Path.home() / ".claude" / "plugins" / "cache"
POLICY_CACHE_FILE = Path.home() / ".claude" / "hooks" / ".policy_cache.json"
CLAUDE_MCP_CONFIG_PATH = _relocated_or_legacy(_CONFIG_DIR / ".claude.json", Path.home() / ".claude.json")
CLAUDE_PLUGIN_CACHE_DIR = _relocated_or_legacy(_CONFIG_DIR / "plugins" / "cache", Path.home() / ".claude" / "plugins" / "cache")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Empty cache dir overrides legacy

Medium Severity

_relocated_or_legacy picks the relocated plugin cache whenever that directory exists. With a custom CLAUDE_CONFIG_DIR, an empty plugins/cache under the new tree can win over a populated legacy ~/.claude/plugins/cache, so _resolve_plugin_mcp_config scans an empty dir and MCP plugin policy resolution fails until cache is repopulated.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d83b1b2. Configure here.

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

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

🟡 LOW — --config-dir=/path (equals form) silently ignored in hooks installer

claude-code/hooks/setup.py:66 · 🔴 HIGH
Impact: Callers using --config-dir=/path with no CLAUDE_CONFIG_DIR get hooks/settings under ~/.claude while Claude reads the custom dir — reproducing the silent policy/telemetry loss this PR fixes. Gateway's argparse path accepts both forms.
Fix: Also match arg.startswith("--config-dir=") and split on the first =.
Reviewers: Cursor, Claude

🟡 LOW — Symlinked config dir breaks hook detection on --clear

claude-code':410 · 🔴 HIGH
Impact: _resolve_claude_config_dir returns a resolved path, but _command_targets_hook compares with normpath only; a lexical hook command in an existing settings.json no longer matches, so --clear can leave enforcement active (and reinstall can duplicate entries).
Fix: Compare Path(tokens[0]).resolve() against target.resolve() (fall back to normpath on resolution failure).
Reviewers: Cursor, Claude

🟡 LOW — Legacy ~/.claude sweep removes unrelated apiKeyHelper

claude-code/gateway/setup.py:494 · 🟡 TRIAGE
Impact: When clearing a relocated install, remove_api_key_helper_setting(default_dir) strips apiKeyHelper from ~/.claude/settings.json unconditionally — potentially breaking a user's own or another tool's key-helper config that was never installed by Unbound.
Fix: In the legacy sweep, only remove apiKeyHelper when its value matches Unbound's anthropic_key.sh path.
Reviewers: Claude

🟡 LOW — Legacy ~/.claude sweep omits hook helper files

claude-code/hooks/setup.py:674 · 🟡 TRIAGE
Impact: The relocated-dir legacy sweep removes unbound.py and hook settings entries under ~/.claude, but not unbound-setup.py or .last_updated, leaving stale artifacts after --clear.
Fix: Extend the legacy sweep to delete the same hook extras as the primary clear path.
Reviewers: Cursor

Previously acknowledged (not re-flagged)

  • Install-time --config-dir vs runtime CLAUDE_CONFIG_DIR divergence — accepted by design: env-first precedence; CLI derives --config-dir from CLAUDE_CONFIG_DIR; runtime must stay env-based to preserve the MDM self-update guard.
  • --clear / --backfill without CLAUDE_CONFIG_DIR missing a custom-dir install — accepted by design: same precedence as install; persistent env is the supported contract; home-anchored install state deferred as follow-up.
  • .claude.json / plugin-cache _relocated_or_legacy exists-probe fallback — deliberate design choice pending empirical verification of Claude Code's relocated layout; residual wrong-identity risk if the relocated file appears only after import is a known/accepted trade-off.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head d83b1b2f · 2026-07-03T07:22Z

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.

3 participants