Skip to content

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

Open
MohamedAklamaash wants to merge 30 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 30 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 policy and telemetry failures when Claude Code is configured with a non-default CLAUDE_CONFIG_DIR by threading the resolved config directory through every installer and runtime artifact path that previously hardcoded ~/.claude.

  • hooks/setup.py and gateway/setup.py: Both add _resolve_claude_config_dir with CLAUDE_CONFIG_DIR env > --config-dir arg > ~/.claude precedence; all hook files, settings.json, the baked hook command, key-helper path, --clear, and --backfill state now resolve under the active config dir. A new legacy sweep in clear_setup also strips leftover enforcement from ~/.claude when clearing a relocated install.
  • claude-code/hooks/unbound.py: _CONFIG_DIR, AUDIT_LOG, ERROR_LOG, POLICY_CACHE_FILE, SELF_SCRIPT_PATH, CLAUDE_SKILLS_ROOT, CLAUDE_PLUGIN_CACHE_DIR, and _APPROVAL_MARKER_FILE all resolve from CLAUDE_CONFIG_DIR at import time; ~ is intentionally not expanded to match Claude Code's own verbatim treatment of the variable.
  • Tests: New gateway and hooks test files cover resolver precedence, install-under-relocated-dir (including the baked command path), backward compat, the foreign-helper ownership guard, and unbound.py path constants — though the hooks clear_setup legacy sweep branch has no test coverage yet.

Confidence Score: 5/5

  • Safe to merge — the default ~/.claude behavior is fully preserved when CLAUDE_CONFIG_DIR is unset, and the new code paths are covered by a solid suite of unit tests including backward-compat and foreign-file ownership scenarios.
  • All changed code paths have either direct test coverage or rely on well-understood stdlib calls (os.path.abspath, Path.resolve). The gateway legacy sweep correctly guards ownership before deletion. The two findings flagged are non-blocking quality improvements — one a minor asymmetry between gateway and hooks cleanup, the other missing parity tests for an already-reviewed branch — neither affects correctness in the default or relocated install case.
  • claude-code/hooks/setup.py (legacy sweep ownership check) and tests/claude_code/hooks/test_setup.py (missing coverage for the relocated-dir clear branch).

Important Files Changed

Filename Overview
claude-code/hooks/unbound.py Module-level _CONFIG_DIR resolved correctly from CLAUDE_CONFIG_DIR at import time with proper whitespace stripping; all runtime paths (audit log, policy cache, approval marker, self-update, skills root) follow the relocated dir. CLAUDE_MCP_CONFIG_PATH placement and plugin cache dir logic look correct.
claude-code/hooks/setup.py Adds _resolve_claude_config_dir and threads config_dir through setup_hooks, configure_claude_settings, clear_setup, and run_backfill. The new legacy sweep in clear_setup (lines 788-792) deletes ~/.claude/hooks/unbound.py without an ownership check, and there is no test covering this branch.
claude-code/gateway/setup.py Adds _resolve_claude_config_dir, updates all key-helper and hook-detection helpers to accept config_dir, and adds a legacy sweep in clear_setup that correctly guards ~/.claude/anthropic_key.sh deletion with _is_unbound_key_helper_file before calling _clear_path.
tests/claude_code/hooks/test_setup.py Good coverage of resolver precedence and install paths, but the hooks clear_setup legacy sweep (the relocated-dir branch) has no corresponding test analogous to TestClearSweepsLegacyDir in the gateway test file.
tests/claude_code/gateway/test_setup.py New test file with thorough coverage: resolver precedence, key-helper writes under custom and default dirs, detect_install_state, hooks stripping, and the legacy sweep including the foreign-helper ownership guard.
tests/claude_code/hooks/test_config_dir.py New test file that imports unbound.py fresh per test case to exercise module-level path resolution. Covers default, relocated, blank, tilde-literal, and plugin-cache-dir scenarios, plus skill resolution against the relocated dir.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["installer invoked\n(setup.py main)"] --> B{CLAUDE_CONFIG_DIR\nenv set & non-blank?}
    B -- yes --> C["config_dir = abspath(CLAUDE_CONFIG_DIR)"]
    B -- no --> D{"--config-dir arg\nprovided?"}
    D -- yes --> E["config_dir = abspath(--config-dir value)"]
    E --> W1["⚠️ warn: CLAUDE_CONFIG_DIR\nnot set — Claude won't load hooks"]
    D -- no --> F["config_dir = ~/.claude  (default)"]
    C --> G["install hooks → config_dir/hooks/unbound.py\nbake absolute path into settings.json"]
    W1 --> G
    F --> G

    G --> H{"clear mode?"}
    H -- no --> Z["done"]
    H -- yes --> I["delete config_dir/hooks/unbound.py\nremove hook entries from config_dir/settings.json"]
    I --> J{"config_dir ≠ ~/.claude\n(relocated install)"}
    J -- yes --> K["legacy sweep:\ndelete ~/.claude/hooks/unbound.py\nstrip hook entries from ~/.claude/settings.json"]
    J -- no --> Z
    K --> Z

    subgraph "unbound.py  (runtime hook)"
        R1["import-time:\n_CONFIG_DIR = abspath(CLAUDE_CONFIG_DIR)\nor ~/.claude if blank/unset"] --> R2["AUDIT_LOG, ERROR_LOG,\nPOLICY_CACHE_FILE, SELF_SCRIPT_PATH,\nCLAUDE_SKILLS_ROOT\nall under _CONFIG_DIR"]
    end
Loading

Reviews (30): Last reviewed commit: "fix: a stale skill in the old home dir m..." | 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 Outdated
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 Outdated
@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 Outdated
….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 Outdated
Comment thread claude-code/hooks/setup.py Outdated

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

  • Installer/runtime --config-dir vs CLAUDE_CONFIG_DIR mismatch — accepted design: env wins; arg-only installs now warn because Claude Code reads the env alone (MohamedAklamaash, AakashVelusamy).
  • --clear / --backfill without persisted custom dir — accepted by design: same resolver as install; requires the profile’s CLAUDE_CONFIG_DIR (MohamedAklamaash).
  • Legacy ~/.claude sweep omits unbound-setup.py / .last_updated — accepted limitation: inert once settings hooks are removed (MohamedAklamaash, AakashVelusamy, Greptile).
  • .claude.json / plugin-cache placement assumptions — resolved against Claude Code 2.1.177; deterministic paths now match the shipped resolver (AakashVelusamy).
  • Semgrep 0o700/0o755 “insecure-file-permissions” — false positive: owner-only modes are intentional for hook/key-helper directories, not a regression from this PR.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 4333f590 · 2026-08-24T14:13Z

staging landed WEB-5526 (#259), which makes each mode remove only the
artifacts Unbound installed rather than anything sharing the conventional
filename. Two conflicts, both the same shape as the unbound-cli side: their
"is this ours" guard against our config-dir parameter. Kept both, so the
guard now runs against the resolved dir instead of a hardcoded ~/.claude —
otherwise a relocated gateway install would clear nothing.

Checked the interaction that bit on the CLI side, where an equivalent guard
matched on the path and so failed to recognise a helper outside a directory
named .claude. This one compares the file body against the text the gateway
writer emits, so it is path-agnostic and a relocated install is recognised
unchanged. No fix needed.

The legacy ~/.claude sweep stays unguarded on purpose: it removes unbound.py
and our own settings entries, which are ours by construction, and it does not
touch anthropic_key.sh there.

Also confirmed while testing Windows semantics that the default-dir comparison
in clear_setup needs no case folding — pathlib compares Windows paths
case-insensitively already, unlike the JS string compare on the CLI side,
which did need it.

Tests: hooks 56 passed, gateway 11 passed, with the pre-existing MDM
matcher-parity failure unchanged. py_compile and pyflakes clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread claude-code/gateway/setup.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)

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)

  • Gateway legacy sweep deletes ~/.claude/anthropic_key.sh without ownership guard (claude-code/gateway/setup.py:676) — Greptile; intentional legacy cleanup on --clear with a relocated config dir (test asserts deletion even for non-Unbound content).
  • Hooks legacy sweep omits unbound-setup.py / .last_updated (claude-code/hooks/setup.py:775) — Greptile/Cursor; accepted limitation — files are inert once the settings hook entry is removed (AakashVelusamy).
  • CLAUDE_CONFIG_DIR env beats --config-dir (claude-code/hooks/setup.py:65) — Greptile; accepted design — Claude Code resolves the config dir from the environment alone, so the arg must not override it (MohamedAklamaash, AakashVelusamy).
  • Clear/backfill without persistent install path — Cursor; by design — --clear/--backfill use the same resolver as install and require the same CLAUDE_CONFIG_DIR Claude uses for that profile (MohamedAklamaash).
  • Semgrep insecure-file-permissions / dynamic-urllib-use-detected — pre-existing patterns unchanged by this diff; not introduced here (Claude).

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 8ad50030 · 2026-08-24T15:25Z

Merging staging's ownership rule left one path uncovered, and it was the
destructive one. WEB-5526 stopped clear from removing an anthropic_key.sh we
did not write, but this branch's legacy ~/.claude sweep — which only runs
when the config dir is relocated — deleted that file unconditionally. Anyone
keeping their own helper of that name would have lost it to a --clear under a
custom CLAUDE_CONFIG_DIR.

The sweep now applies the same content check as the primary path, so it
removes only the script the gateway writer emitted.

The existing sweep test wrote "echo x" as the legacy helper and asserted it
was deleted, which predates the ownership rule and would now be asserting the
wrong thing; it writes the real helper body instead. A new test covers the
case that matters, that a foreign helper survives while our own install is
still cleared.

Left the hooks sweep alone deliberately: it removes unbound.py, which nothing
else writes, and the primary clear path deletes it unguarded for the same
reason. Guarding one and not the other would be the inconsistency, not the fix.

Tests: hooks 56 passed, gateway 12 passed, with the pre-existing MDM
matcher-parity failure unchanged. py_compile and pyflakes clean.

Co-Authored-By: Claude Opus 5 (1M context) <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)

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

🔴 HIGH — Legacy clear strips foreign apiKeyHelper from default settings.json

claude-code/gateway/setup.py:676 · Impact: When clearing a relocated install, remove_api_key_helper_setting(default_dir) runs with no ownership check on the setting value, so a user's unrelated apiKeyHelper in ~/.claude/settings.json can be removed even though this install never touched that tree. · Fix: Gate the legacy settings call the same way the primary path guards the helper file (e.g. only remove when _is_unbound_key_helper_setting matches), and add a test with a foreign apiKeyHelper in legacy settings.json. · Flagged by: Greptile, Claude

🟡 TRIAGE — Relative CLAUDE_CONFIG_DIR anchors enforcement state in a cwd-relative, repo-writable tree

claude-code/hooks/unbound.py:25 · Impact: A relative value (e.g. .claude) resolves against the hook's cwd, so policy cache, approval marker, and audit log can land under a project directory; a checked-in .policy_cache.json or .approval_pending may be read as trusted hook state. · Fix: Keep security-sensitive artifacts home-anchored under ~/.unbound/ (as injection-turn guard already is), or reject non-absolute CLAUDE_CONFIG_DIR for cache/approval paths. · Flagged by: Claude

🟡 TRIAGE — POSIX hook command baked without quoting

claude-code/hooks/setup.py:532 · Impact: The hook path written to settings.json is an unquoted bare string on POSIX; a CLAUDE_CONFIG_DIR or --config-dir containing spaces or shell metacharacters can produce a malformed or injectable command string. · Fix: Quote on all platforms (shlex.quote on POSIX, matching the Windows branch) and reject config-dir values with shell metacharacters at resolution time. · Flagged by: Claude

Previously acknowledged (not re-flagged)

  • Env-before-arg precedence (CLAUDE_CONFIG_DIR wins over --config-dir) — accepted design: Claude Code reads the env var alone; arg-only installs now warn instead of silently succeeding.
  • Legacy sweep omits unbound-setup.py / .last_updated in ~/.claude — accepted limitation; files are inert once hook settings entries are removed.
  • --clear / --backfill without persistent CLAUDE_CONFIG_DIR — accepted by design; same resolution precedence as install; ad-hoc-env gap deferred as follow-up.
  • Install/runtime config-dir mismatch, whitespace handling, symlink matching, .claude.json placement, MDM self-update guard — addressed or resolved in prior commits per maintainer thread.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head a116048a · 2026-08-24T15:52Z

Dropped the fourth line, which restated the consequence already implied by
the two above it. The rule a maintainer needs — verbatim, no tilde expansion,
blank means unset — is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 24, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@AakashVelusamy

Copy link
Copy Markdown
Contributor

Comment-disposition audit — merge gate

Every review finding on this PR, current and earlier rounds, with where it stands. Head is b1bde03.

Greptile

Finding Disposition
P1 — legacy sweep deletes anthropic_key.sh without an ownership check. Merging staging's WEB-5526 rule left this path uncovered: a relocated --clear would delete a helper of that name we did not write. Fixed in a116048. This was destructive and mine — I applied the ownership guard to the primary path and missed the legacy sweep. It now uses the same content check. The existing sweep test wrote "echo x" and asserted deletion, which predates the ownership rule; it writes the real helper body now, and a new test covers a foreign helper surviving while our own install is still cleared.
unbound-setup.py and .last_updated not swept from legacy ~/.claude/hooks/ during a relocated clear. Accepted limitation. Inert once the settings entry is gone — nothing loads them and the enforcement path is fully cleared. Extending deletion behaviour in the default dir is where the blast radius is worst if wrong; worth a follow-up, not worth doing at a merge gate. Greptile reached the same conclusion and still scored 5/5.
Current score: 5/5.

Cursor — all four inline comments, verified against the current code

Finding Disposition
MDM self-update guard bypassed (High) — deriving _CONFIG_DIR from Path(__file__).parents[1] made SELF_SCRIPT_PATH always match the running hook. Fixed_CONFIG_DIR comes from the environment, and the self-update check deliberately compares against the running os.path.abspath(__file__) rather than SELF_SCRIPT_PATH, precisely because MDM executes the hook from elsewhere.
Hook config dir ignores CLI arg (High) — setup.py honours --config-dir but the runtime hook resolves only from the environment. Addressed in cba0181 by making it loud rather than threading the arg through. Claude Code keys off the environment alone, so an install aimed elsewhere by the arg is one Claude never reads at all — no hook-side resolution rescues it. The CLI never produces this state; it is reachable by invoking setup.py directly, where it used to report success over hooks that could not fire.
Symlinked config breaks hook matching (Medium) — the resolver returned a realpath while _command_targets_hook compares lexically, so a symlinked ~/.claude could leave hooks installed after --clear. Fixed in bb10ed3, as a side effect of matching Claude Code. Dropping resolve() for a lexical abspath puts both sides of the comparison back on the same footing. Node's path.resolve, which Claude uses, is also lexical — so this is the faithful behaviour, not a workaround.
Legacy clear omits hook files (Low) Accepted limitation — same as the Greptile note above.
Cursor Security Agent: pass. Bugbot: skipped (on-demand usage disabled). No action / accepted.

Consensus / security review

0 findings — 0 high-confidence, 0 to triage.

Carried over from the June thread

Item Disposition
".claude.json placement under a relocated dir is not pinned by the docs — worth an empirical probe before merge." Resolved. It relocates. CLAUDE_CONFIG_DIR=<dir> claude mcp list on 2.1.177 created <dir>/.claude.json and left $HOME untouched, and the shipped bundle shows the rule is unconditional: .claude.json is CLAUDE_CONFIG_DIR || homedir(), plugins are join(configDir,"plugins") unless CLAUDE_CODE_PLUGIN_CACHE_DIR overrides, user skills are join(configDir,"skills"). _relocated_or_legacy was removed as a result — its existence probe could read ~/.claude.json, which Claude was not using.

Found by testing, not by a bot

Finding Disposition
Our own tilde expansion was the bug — Claude Code creates a directory literally named ~ rather than expanding it, so the installers sent hooks to $HOME/x while Claude read ./~/x. Fixed in bb10ed3; regression test pins it. Removing the expansion deleted code.
Blank CLAUDE_CONFIG_DIR resolves to the cwd in Claude Code — observed writing backups/ into the cwd with "", and creating a directory named three spaces with " ". Reproduced on 2.1.177 and 2.1.179. Fixed in 4333f59. We keep the ~/.claude fallback rather than scattering hook scripts through whatever directory setup ran in — Claude is itself inconsistent here (?? for the config dir, || for .claude.json), which reads as an accident rather than a contract. Both installers now say so plainly instead of installing silently.
--config-dir=VALUE silently dropped by the hand-rolled argv scan, falling back to ~/.claude. Fixed in 489bdbc. The equals form is what a person types by habit when running setup.py directly; the CLI only ever emits the space form. The arg-without-env warning recognises both spellings too.

Verification

hooks 56 passed, gateway 12 passed. The single hooks failure (TestMatcherParityAcrossTrees) reproduces byte-identically on untouched staging — I hashed the extracted bodies on both trees and got the same two groups; the drift is between the two MDM trees this branch does not touch. py_compile and pyflakes clean across every *.py. Cross-OS: macOS 26.5 / Claude Code 2.1.177 and Ubuntu 22.04 / 2.1.179; end-to-end install confirmed Claude independently picks the same relocated directory, with nothing stranded in ~/.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)

  • Env-before---config-dir precedence — accepted design: Claude Code keys off CLAUDE_CONFIG_DIR alone; letting the CLI arg override env would install where Claude never reads.
  • Install/runtime config-dir mismatch (--config-dir vs env) — accepted: normal CLI flow forwards env into the child; divergence is only reachable by hand-invoking setup.py with mismatched values.
  • --clear / --backfill without CLAUDE_CONFIG_DIR on a prior custom-dir install — accepted by design: same resolver precedence as install; persistent env is the supported contract (follow-up state file deferred).
  • Legacy ~/.claude sweep leaves unbound-setup.py / .last_updated — accepted limitation: inert once hook settings are removed; extra deletion in the default dir is higher blast-radius.
  • .claude.json / .config.json placement under relocated dirs — resolved empirically against Claude Code 2.1.177; .config.json preference intentionally not probed at import (known limitation, no enforcement impact).
  • --config-dir without CLAUDE_CONFIG_DIR — addressed with an explicit install-time warning rather than silent success.
  • Legacy sweep deleting foreign anthropic_key.sh — fixed (a116048): legacy path now uses the same ownership/content check as the primary clear path.
  • Symlinked config dir breaking hook matching on clear — fixed: resolver uses lexical abspath to match Claude Code / hook-matcher semantics.
  • Semgrep insecure-file-permissions (0o700 / 0o755) — pre-existing, intentional executable/private-dir modes for hook scripts; not introduced or widened by this diff.
  • Semgrep dynamic-urllib-use in tests — pre-existing test-only localhost callback exercise; no production attack surface.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head b1bde030 · 2026-08-24T20:21Z

Three predicates still asked "is this ours" against a hardcoded ~/.claude
while the install itself had moved. Each is the same bug this branch exists
to fix, one level down.

The damaging one is the gateway's apiKeyHelper. setup_claude_key_helper
writes an absolute path when the dir is relocated, but
_is_unbound_key_helper_setting only recognised the ~ form, so clear deleted
the script and left the setting pointing at it. Claude Code then holds a
dangling apiKeyHelper and every API call fails — a worse state than before
clearing.

_is_unbound_hook_command was pinned to ~/.claude/hooks/unbound.py, so moving
from hooks mode to gateway mode under a custom dir would not strip the
existing hooks and both enforcement paths would fire at once.

The hooks installer had the same blind spot in reverse: it drops the
gateway's apiKeyHelper before installing, and could not see the absolute form
either, so the helper would have survived and driven Claude alongside the
hooks.

All three now take the resolved dir and still accept the portable ~ form, so
a default install behaves exactly as before. Tests cover the dangling-setting
case and the hooks-to-gateway transition, which are the two that reach a user.

Tests: hooks 58 passed, gateway 14 passed, with the pre-existing MDM
matcher-parity failure unchanged. py_compile and pyflakes clean.

Co-Authored-By: Claude Opus 5 (1M context) <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)

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

🔴 HIGH — Unquoted hook command enables shell injection / silent bypass

claude-code/hooks/setup.py:537
Impact: The baked settings.json hook command is the bare POSIX path (str(hook_path)); paths with spaces fail to launch (PreToolUse never fires), and paths containing ;, `, or $(...) inject into the shell Claude Code uses on every tool call.
Fix: Quote the baked path on all platforms (shlex.quote on POSIX; keep existing Windows quoting) and reject config-dir values containing shell metacharacters or newlines in _resolve_claude_config_dir.
Flagged by: Claude, Lead

🟡 TRIAGE — Relative CLAUDE_CONFIG_DIR anchors enforcement state in the repo cwd

claude-code/hooks/unbound.py:25
Impact: A relative env value (e.g. CLAUDE_CONFIG_DIR=.cc) is abspath-ed against the session cwd, so .policy_cache.json, .approval_pending, and agent-audit.log live inside the working tree; repo-controlled cache/marker files could influence policy decisions or leak tool-call history into the tree.
Fix: Reject non-absolute CLAUDE_CONFIG_DIR at install/runtime, or keep enforcement-critical files home-anchored under ~/.unbound/ (as INJECTION_TURN_GUARD_DIR already is).
Flagged by: Claude, Lead

🟡 TRIAGE — Key-helper ownership verified against the wrong file

claude-code/hooks/setup.py:227, claude-code/gateway/setup.py:320
Impact: For a relocated config dir, a settings apiKeyHelper of ~/.claude/anthropic_key.sh is accepted as “ours” when config_dir/anthropic_key.sh is missing, so install/clear can strip a third-party default-dir helper and break API auth.
Fix: Resolve ownership from the path named in the setting (expand ~ if needed) and pass that path to _is_unbound_key_helper_file, not config_dir / "anthropic_key.sh".
Flagged by: Claude, Lead

Previously acknowledged (not re-flagged)

  • Empty CLAUDE_CONFIG_DIR fail-open — installer warns instead of refusing; maintainer chose this deliberately over matching Claude Code’s cwd-relative empty-string behavior.
  • Env-before---config-dir precedence — accepted design: Claude Code reads the env var alone, so the arg cannot override it without creating an install Claude never loads.
  • --clear / --backfill require the same CLAUDE_CONFIG_DIR — by design; ad-hoc-env-only installs are a known narrow gap, not in WEB-4882 scope.
  • Legacy ~/.claude sweep leaves unbound-setup.py / .last_updated — accepted limitation; files are inert once hook settings entries are removed.
  • Semgrep 0o700/0o755 chmod findings — pre-existing intentional permissions for executable helpers/scripts, not introduced by this change.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 976ffb92 · 2026-08-24T20:43Z

The sync writes managed skills to <config dir>/skills, which is also where
Claude Code reads user-level skills, but _resolve_skill_path only ever looked
under ~/.claude/skills. On a relocated install we therefore wrote skills to
one directory and searched another, so every Unbound-managed skill failed to
resolve and the backend got no path to join against.

The search list now includes the resolved skills root alongside the existing
roots rather than replacing any of them, so project-scoped and directory-
scoped lookups are untouched and the default dir resolves exactly as before —
it simply checks the same directory twice there.

Tests cover both directions: a skill resolving under a relocated dir, and the
default dir still resolving as it did.

Tests: hooks 60 passed, gateway 14 passed, with the pre-existing MDM
matcher-parity failure unchanged. py_compile and pyflakes clean.

Co-Authored-By: Claude Opus 5 (1M context) <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)

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

🔴 HIGH — Unquoted config dir baked into shell-executed settings

claude-code/hooks/setup.py:537, claude-code/gateway/setup.py:513

  • Impact: _resolve_claude_config_dir() output is written unquoted into hooks[].command and apiKeyHelper; a CLAUDE_CONFIG_DIR containing shell metacharacters (e.g. from MDM, Dockerfile ENV, direnv) persists as command execution on every hook/API-key fetch.
  • Fix: Reject config-dir values outside a conservative path charset in both resolvers, and quote the baked path on POSIX (Windows already quotes).
  • Flagged by: Claude, Lead

🟡 TRIAGE — Key-helper ownership probe targets wrong file

claude-code/hooks/setup.py:224, claude-code/gateway/setup.py:318

  • Impact: _is_unbound_key_helper_setting accepts the portable ~/.claude/anthropic_key.sh form but probes config_dir/anthropic_key.sh; when that file is absent on a relocated install, a third-party apiKeyHelper can be misclassified as Unbound-owned and removed.
  • Fix: Derive the path from the setting value and run the ownership check on that file, not a fixed config_dir sibling.
  • Flagged by: Claude

🟡 TRIAGE — Legacy clear deletes foreign unbound.py without ownership check

claude-code/hooks/setup.py:782

  • Impact: When clearing a relocated config dir, the legacy sweep unconditionally unlinks ~/.claude/hooks/unbound.py, which can delete a same-named hook the gateway sweep was just hardened to avoid for anthropic_key.sh.
  • Fix: Gate the legacy delete on a content/ownership check before unlinking (mirror _is_unbound_key_helper_file on the gateway path).
  • Flagged by: Claude, Lead

Previously acknowledged (not re-flagged)

  • Install --config-dir vs runtime env mismatch — Accepted design: runtime resolves from CLAUDE_CONFIG_DIR to preserve the MDM self-update guard; the supported CLI flow sets env first (MohamedAklamaash).
  • --clear / --backfill without persisted install path — Accepted by design: same env precedence as install; clearing a custom profile requires the same CLAUDE_CONFIG_DIR Claude uses (MohamedAklamaash).
  • Env beats --config-dir precedence — Accepted design choice: Claude Code reads the env var alone, so arg-over-env would install where Claude never loads (AakashVelusamy).
  • Legacy sweep leaves unbound-setup.py / .last_updated in ~/.claude — Accepted limitation: inert once settings entries are removed; widening deletion in the default dir is out of scope (AakashVelusamy, Greptile).
  • Semgrep 0o700/0o755 chmod findings — Pre-existing intentional permissions on helper/script files, not introduced by this config-dir routing change.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head a126453a · 2026-08-24T21:12Z

Two from review, both mine.

_is_unbound_key_helper_setting checked ownership of the active dir's
anthropic_key.sh whatever the setting said. A portable ~ form left over from
a default install names ~/.claude, so a script sitting in the relocated dir
was vouching for a file it has nothing to do with — it could refuse to clear
a stale default-dir setting, or clear one that is not ours. It now picks the
target from the candidate and judges that file. Both installers carried the
same shape, so both are fixed.

The two test classes I appended to test_config_dir.py landed after the
unittest.main() guard, so running the file directly defined them too late to
be collected — 11 of 15 tests ran, and the four covering this branch's own
behaviour were among the silent four. The guard now sits at the end.

Tests: hooks 62 passed via unittest and 16 via direct execution, gateway 14
passed, with the pre-existing MDM matcher-parity failure unchanged.
py_compile and pyflakes clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 24, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@AakashVelusamy

Copy link
Copy Markdown
Contributor

Comment-disposition audit — merge gate, round 3

Additions since the last audit. Everything from the earlier rounds still stands as recorded there.

Greptile — this round

Finding Disposition
Managed skill discovery broken under a custom dir. Skills synced to CLAUDE_SKILLS_ROOT were invisible to _resolve_skill_path, which only searched ~/.claude/skills. Fixed in a126453. We wrote skills to one directory and looked in another, so every Unbound-managed skill silently failed to resolve. Worth being plain about this: I saw the same inconsistency during the first merge and deferred it as pre-existing staging code. That was the wrong call — once this branch made the sync target follow the config dir, the write/read pairing became inconsistent within our own code, which made it ours. The search list now appends the resolved skills root rather than replacing any existing root, so project-scoped and directory-scoped lookups are untouched and the default dir behaves exactly as before. Tests cover both directions.
_is_unbound_key_helper_setting judged the wrong file. It checked ownership of config_dir/anthropic_key.sh regardless of which path the setting named. Fixed in the latest commit. A portable ~ form left over from a default install names ~/.claude, so a script in the relocated dir was vouching for a file it has nothing to do with — able to refuse clearing a stale default-dir setting, or to clear one that is not ours. The target is now derived from the candidate. Both installers carried the same shape; both fixed.
Two test classes placed after the unittest.main() guard, invisible to direct execution. Fixed in the latest commit. Real defect and mine: python test_config_dir.py ran 11 of 15 tests, and the four covering this branch's own behaviour were among the silent ones. They only ever ran because I invoked -m unittest, which imports before collecting. The guard now sits at the end of the file; direct execution runs all 16.

Earlier rounds — unchanged

Ownership predicates following the relocated dir (976ffb9), the destructive legacy-sweep guard (a116048), the blank-value warning (4333f59), the --config-dir=VALUE parser gap (489bdbc), the tilde-literal fix and _relocated_or_legacy removal (bb10ed3), and the four Cursor inline comments — all as previously recorded. The unbound-setup.py / .last_updated legacy-sweep omission remains an accepted limitation: inert files, no enforcement impact, and widening deletion behaviour in the default dir is the riskiest place to be wrong.

Verification

hooks 60 passed / 1 failed of 61 under unittest, and 16 passed under direct execution; gateway 14 passed. The single failure (TestMatcherParityAcrossTrees) reproduces byte-identically on untouched staging — same two hash groups — and concerns the two MDM trees this branch does not touch. py_compile and pyflakes clean across every *.py.

@AakashVelusamy

Copy link
Copy Markdown
Contributor

@greptileai please re-review — head is now de1fad4, past the commit the current score was taken on.

Both findings from your last pass are addressed:

Skill discovery under a custom dir_resolve_skill_path now includes the resolved skills root, so what the sync writes is what resolution finds. The root is appended to the existing search list rather than replacing any, so project-scoped and directory-scoped lookups are untouched and the default dir resolves exactly as before. Tests cover both directions.

_is_unbound_key_helper_setting judging the wrong file — it checked ownership of the active dir's helper whatever path the setting named, so a leftover portable ~ form was being vouched for by an unrelated script in the relocated dir. The target is now derived from the candidate, in both installers.

Also fixed a defect in my own tests that your note surfaced: two classes sat after the unittest.main() guard, so direct execution collected 11 of 15. The guard moved to the end; python test_config_dir.py now runs all 16.

Tests: hooks 60 passed of 61 under unittest (the one failure is TestMatcherParityAcrossTrees, which reproduces byte-identically on untouched staging and concerns the two MDM trees this branch does not touch), 16 passed under direct execution, gateway 14 passed. py_compile and pyflakes clean.

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

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

🟡 TRIAGE — Relative CLAUDE_CONFIG_DIR relocates trust-bearing hook state into the project tree

claude-code/hooks/unbound.py:24 (also claude-code/hooks/setup.py:79, claude-code/gateway/setup.py:469)

Impact: A non-absolute CLAUDE_CONFIG_DIR (e.g. .claude) is resolved with os.path.abspath against the process cwd, so POLICY_CACHE_FILE, _APPROVAL_MARKER_FILE, audit/error logs, and related paths land inside whatever repo Claude is running in — unlike the prior home-anchored layout. A checked-out tree could ship .claude/hooks/.policy_cache.json or .approval_pending that the hook treats as trusted local state (300s cache TTL; approval-retry short-circuit), weakening policy enforcement or approval gates for that workspace.

Fix: For trust-bearing artifacts only, keep using a home-anchored root (e.g. ~/.unbound/) regardless of config dir, or reject non-absolute CLAUDE_CONFIG_DIR at install/runtime with a warning; alternatively require cryptographic validation of cache/marker contents before trust (confirm whether readers already do this).

Reviewers: Claude

Previously acknowledged (not re-flagged)

  • Empty CLAUDE_CONFIG_DIR divergence — hook/installer fall back to ~/.claude and warn; Claude Code treats empty as cwd-relative. Accepted as deliberate warn-only behavior (test_config_dir.py, setup.py main()).
  • Env-before---config-dir precedence — accepted design: Claude Code reads the environment variable alone; arg-only installs now warn instead of silently succeeding.
  • --clear / --backfill without persistent CLAUDE_CONFIG_DIR — accepted: same precedence as install; ad-hoc-env gap deferred to follow-up state persistence.
  • Legacy ~/.claude sweep leaves unbound-setup.py / .last_updated — accepted limitation; inert once settings entries are removed.
  • Greptile P1 legacy anthropic_key.sh deletion without ownership check — fixed in a116048; not re-raised.
  • Semgrep 0o700/0o755 file-permission hits — false positive for this use case (owner-only hook/config dirs are appropriate; not introduced by this change).
  • Semgrep dynamic urllib in test_setup.py — test-only localhost callback harness; out of scope for production security.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head de1fad4f · 2026-08-24T21:32Z

staging landed WEB-5563, which matches the Cowork entrypoint by name rather
than an exact value. It touches unbound.py, the same file this branch edits,
so it merged clean but was worth reading rather than trusting: the change is
confined to CLAUDE_CODE_ENTRYPOINT labelling and shares nothing with the
config-dir resolution here.

Tests: hooks 60 passed of 61 under unittest and 16 under direct execution,
gateway 14 passed. The one failure is the pre-existing MDM matcher parity.
py_compile and pyflakes clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AakashVelusamy

Copy link
Copy Markdown
Contributor

Comment-disposition audit — merge gate, round 4

No new findings this round. Head moved only because staging advanced.

staging landed WEB-5563 (#261), which touches unbound.py — the same file this branch edits — so it merged clean but was read rather than trusted, given two earlier staging merges introduced interaction bugs that were invisible in either branch alone. This one is confined to CLAUDE_CODE_ENTRYPOINT labelling for Cowork and shares nothing with config-dir resolution. No interaction.

Everything from rounds 1–3 stands as recorded: the ownership predicates following the relocated dir, the destructive legacy-sweep guard, the skill-resolution fix, the _is_unbound_key_helper_setting target derivation, the test-collection defect, the blank-value warning, and the --config-dir=VALUE parser gap. The unbound-setup.py / .last_updated legacy-sweep omission remains an accepted limitation — inert files, no enforcement impact.

Verification on this head: hooks 60 passed of 61 under unittest and 16 under direct execution; gateway 14 passed. The single failure (TestMatcherParityAcrossTrees) reproduces byte-identically on unmodified staging and concerns the two MDM trees this branch does not touch — Greptile independently reached the same conclusion. py_compile and pyflakes clean across every *.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)

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)

  • Legacy sweep leaves unbound-setup.py / .last_updated in ~/.claude — Accepted limitation: files are inert once hook settings entries are removed; widening deletion in the default dir was judged higher blast radius than the cosmetic leftover (Greptile, maintainer).
  • --clear / --backfill without CLAUDE_CONFIG_DIR on a custom-dir install — Accepted by design: same precedence as install (CLAUDE_CONFIG_DIR--config-dir → default); clearing a relocated profile requires the same env Claude uses to load it (maintainer).
  • Env beats --config-dir precedence — Accepted design choice: Claude Code keys off the environment variable alone; arg overriding env would install where Claude never reads (maintainer).
  • --config-dir without env installs to unreachable tree — Addressed via explicit warning in setup.py rather than silent success; not a reachable path through unbound-cli (maintainer).
  • .config.json vs .claude.json under relocated dir — Known limitation: Claude prefers <configDir>/.config.json when present; hook reads .claude.json only; accepted as no enforcement impact, avoids import-time probe on every hook invocation (maintainer).
  • Semgrep insecure-file-permissions on 0o700 / 0o755 — Pre-existing restrictive modes, not a widening of access; false positive on Semgrep’s “use 644” heuristic (deterministic scanner).

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 270a4be9 · 2026-08-25T12:31Z

Running them on Windows Server surfaced 13 failures, all in the tests rather
than the code — the resolver was right every time and the expectations were
POSIX-only.

Two causes. The fake home set HOME alone, but expanduser reads USERPROFILE on
Windows, so Path.home() kept returning the real profile and every default-dir
assertion compared against the wrong directory; _reload now sets both.
And a root-relative literal like /opt/cc is stored as C:\opt\cc on Windows,
since abspath prepends the current drive — the expectations now go through
the same call rather than hardcoding the POSIX spelling.

A suite that only passes on POSIX gives false confidence about the platform
whose path rules differ most, which is the one this branch had to reason
about hardest. It now runs green on macOS, Linux and Windows, under both
`python -m unittest` and direct execution.

Co-Authored-By: Claude Opus 5 (1M context) <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)

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

🔴 HIGH — Unquoted config-dir paths in shell-executed settings.json commands

claude-code/hooks/setup.py:542, claude-code/gateway/setup.py:517
Impact: Resolved CLAUDE_CONFIG_DIR / --config-dir values are written bare into hook command strings and apiKeyHelper on POSIX; Claude Code executes these via a shell, so paths with spaces break the hook silently and paths with shell metacharacters (e.g. from MDM/Docker ENV) can turn the enforcement line itself into arbitrary command execution.
Fix: Quote POSIX paths the way the Windows branch already does (shlex.quote(str(script_path)) / same for apiKeyHelper), or reject resolved dirs containing shell-unsafe characters in _resolve_claude_config_dir.
Flagged by: Claude, Cursor

🔴 HIGH — Blank CLAUDE_CONFIG_DIR still reports install success to backend

claude-code/hooks/setup.py:1343
Impact: When CLAUDE_CONFIG_DIR is set but whitespace-only, main() warns that Claude Code will not load this install, yet still installs into ~/.claude and calls notify_setup_complete(...) — the fleet backend can record the device as enrolled/protected while no hook ever fires for the active Claude profile.
Fix: Exit non-zero before install/notify in the blank-env case, or pass an explicit broken/unreachable install state to notify_setup_complete so the dashboard does not show a false positive.
Flagged by: Claude, Cursor

Previously acknowledged (not re-flagged)

  • Env-before-arg precedence — accepted design: Claude Code keys off the environment variable alone; arg overriding env would install where Claude never reads.
  • --config-dir without CLAUDE_CONFIG_DIR — addressed by warning (cba0181); hooks cannot fire in that state by construction.
  • Legacy sweep leaves unbound-setup.py / .last_updated — accepted limitation; inert once settings entries are removed.
  • .claude.json / plugin-cache placement — resolved against Claude Code 2.1.177 bundle + live probe; deterministic rules now match.
  • Literal ~ non-expansion, symlink/realpath mismatch, legacy anthropic_key.sh deletion, install/runtime dir divergence, clear-without-env — fixed or accepted in prior maintainer replies.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head a0819d5f · 2026-08-25T14:33Z

staging landed WEB-5524, which collects every suite under tests/ as a pytest
package and gates CI on it. That is the first time this repo's tests run in
CI at all, which is worth having: the config-dir tests added here were only
ever exercised by hand.

The one conflict was in the hooks test file, where the restructure rewrote
every import to load a tool's module by repo-relative path — several tools
ship a setup.py, so a bare `import setup` resolves to whichever directory
lands on sys.path first. Took the restructured file whole and re-applied this
branch's two classes onto it rather than merging hunk by hunk, since the
import style changed on every one.

The other two files moved with it. test_config_dir.py needed more than a
move: it reloaded the shared module to re-resolve paths under a patched
environment, and reload cannot work on a module loaded under a synthetic
package name. Each case now imports unbound afresh from its path, which is
closer to what the test means anyway — every path resolves at import, so a
case wants its own module rather than a mutated shared one. tearDown went
away with it, having nothing left to restore.

pytest: 1910 passed, 39 skipped. The MDM matcher-parity case that failed
throughout this branch's life now passes — staging fixed the drift between
the two trees, so the suite is green with nothing left to explain away.

Co-Authored-By: Claude Opus 5 (1M context) <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)

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

🔴 HIGH — Gateway install succeeds into a directory Claude Code never reads

claude-code/gateway/setup.py:886
Impact: setup.py --config-dir /opt/cc (with CLAUDE_CONFIG_DIR unset) writes anthropic_key.sh and apiKeyHelper under /opt/cc while Claude Code still reads ~/.claude, so gateway proxying, logging, and DLP inspection are silently bypassed with no operator signal.
Fix: Mirror the hooks installer warning in gateway main(): if --config-dir is set and CLAUDE_CONFIG_DIR is blank/unset, print the same “Claude Code reads only the environment variable” notice (consider non-zero exit).
Flagged by: Claude, Greptile, Lead

🟡 TRIAGE — Relocated config dir not validated for ownership or writability

claude-code/hooks/setup.py:476 (also claude-code/gateway/setup.py:487)
Impact: Installers now create and bake executable hook/key-helper paths under any resolved CLAUDE_CONFIG_DIR/--config-dir (including relative cwd paths, /tmp, or other world-writable parents) without checking ownership or permissions; a local attacker who can write there can replace hooks/unbound.py or anthropic_key.sh and run arbitrary code as the user on every tool call, defeating policy enforcement.
Fix: Before writing, stat the resolved config dir (and parents created); refuse or abort if not owned by the current uid or group/world-writable; create the dir with 0o700 and write hook/helper files with explicit restrictive modes.
Flagged by: Claude, Lead

Previously acknowledged (not re-flagged)

  • Env-before-arg precedence (CLAUDE_CONFIG_DIR wins over --config-dir) — accepted design: Claude Code keys off the environment alone; arg-only installs are warned, not silently treated as authoritative.
  • Literal ~ in CLAUDE_CONFIG_DIR not expanded — intentional to match Claude Code 2.1.177 verbatim resolution.
  • Legacy sweep omits unbound-setup.py / .last_updated in ~/.claude — accepted limitation; files are inert once the settings entry is removed.
  • Clear/backfill without persisted CLAUDE_CONFIG_DIR — by design; operating on a relocated profile requires the same env Claude uses.
  • Legacy sweep deleting foreign anthropic_key.sh — fixed in a116048 with ownership/content guard; not re-flagged.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 73be66dd · 2026-08-25T15:22Z

The hooks installer says so when --config-dir is passed without
CLAUDE_CONFIG_DIR in the environment; the gateway did not. Claude Code picks
its config dir from the environment alone, so an install aimed elsewhere by
the flag writes a key helper Claude never finds, then reports success. The
user is left with no working apiKeyHelper and failing API calls, with nothing
having said anything.

This is the fourth time on this branch a guard landed on one side of a pair,
so rather than add the one line I compared every config-dir guard across both
installers: resolver, blank-env warning, arg-without-env warning, legacy
sweep, and the two ownership predicates. They match now. The one remaining
difference is deliberate — the gateway clears the key helper in two places
because it owns it, while hooks only removes a leftover one.

pytest: 1910 passed, 39 skipped. py_compile and pyflakes clean.

Co-Authored-By: Claude Opus 5 (1M context) <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)

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

🔴 HIGH — Unquoted config-dir paths written into shell-executed settings.json values

claude-code/gateway/setup.py:517, claude-code/hooks/setup.py:542
Impact: Resolved CLAUDE_CONFIG_DIR / --config-dir is written verbatim into apiKeyHelper (gateway, relocated installs) and the baked hook command (hooks); on POSIX these are unquoted while Windows is quoted. A value containing shell metacharacters (e.g. from MDM/Dockerfile ENV) can turn apiKeyHelper into persistent command execution on every API-key resolution; paths with spaces split the hook command so enforcement never runs.
Fix: shlex.quote() both values on POSIX before writing settings.json, or reject config dirs containing whitespace/shell metacharacters at resolve time; ensure _command_targets_hook tokenizes with the same rules used when writing (e.g. shlex.split) so --clear still matches quoted commands.
Reviewers: Claude, Cursor

🟡 TRIAGE — Legacy ~/.claude sweep deletes unbound.py without an ownership check

claude-code/hooks/setup.py:786
Impact: When clearing a relocated install, _clear_path(default_dir / "hooks" / "unbound.py", …) removes any file with that name in the default tree; a third-party or org-authored hook of the same name is deleted even though the clear targets a different config dir.
Fix: Gate legacy unbound.py removal on the same content/signature check used elsewhere (mirroring the gateway legacy anthropic_key.sh guard added in a116048).
Reviewers: Claude

Previously acknowledged (not re-flagged)

  • CLAUDE_CONFIG_DIR env beats --config-dir arg — accepted design: Claude Code keys off the env var alone; arg-only installs now warn instead of silently succeeding (MohamedAklamaash / AakashVelusamy).
  • --clear / --backfill without persisted install path — accepted by design: same resolver precedence as install; operating on a relocated profile requires the same CLAUDE_CONFIG_DIR Claude uses (MohamedAklamaash).
  • Legacy sweep leaves unbound-setup.py / .last_updated in ~/.claude — accepted limitation: inert once the settings hook entry is removed (Greptile / AakashVelusamy).
  • Symlinked config-dir hook matching — fixed via lexical abspath instead of resolve(); residual .resolve() in relocated-vs-default equality branches is a known, low-impact inconsistency (AakashVelusamy).
  • Semgrep insecure-file-permissions (0o700 / 0o755) — pre-existing intentional executable/script permissions, not a regression introduced by this change.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 828016c9 · 2026-08-25T15:53Z

The earlier skill-resolution fix appended the config dir's skills root to the
search list to avoid disturbing the existing entries. That left ~/.claude/skills
ahead of it, and after a move the sync only refreshes the relocated dir — so a
copy left behind in the old location would win every lookup and stay frozen at
whatever it last held. Being non-destructive about the ordering made the result
wrong in the case the fix existed for.

The config dir's skills root now stands in for the home-anchored user entry
rather than queuing behind it, which is also what Claude Code does: user scope
is join(configDir, "skills"), project scope is <cwd>/.claude/skills. Project
and directory-scoped lookups are untouched, and with the variable unset the two
are the same path, so nothing changes for a default install.

The previous test passed only because it never put a competing skill in the old
location. There is now one that does, and it fails against the old ordering.

pytest: 1911 passed, 39 skipped. py_compile and pyflakes clean.

Co-Authored-By: Claude Opus 5 (1M context) <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)

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

🟡 TRIAGE — Relocated --clear may disable an unrelated default profile

claude-code/hooks/setup.py:785 · claude-code/gateway/setup.py:681

  • Impact: Clearing with CLAUDE_CONFIG_DIR pointed at profile B also strips ~/.claude/hooks/unbound.py and Unbound hook entries from ~/.claude/settings.json, even when profile A (default dir) is still a live, separate install — silently dropping policy enforcement/telemetry for a profile never named on the command line.
  • Fix: Gate the legacy sweep on evidence the default tree is a leftover (e.g., no other active hook config, or an explicit --clear-legacy flag) and log exactly what is removed.
  • Reviewers: Claude

🟡 TRIAGE — Relative CLAUDE_CONFIG_DIR can drop credential helper into cwd

claude-code/gateway/setup.py:466

  • Impact: Unvalidated relative values are absolutized against the installer's cwd, so CLAUDE_CONFIG_DIR=cc from a repo checkout can write anthropic_key.sh (surfacing the Unbound API key) inside that tree — commit or shared-host exposure risk; empty values warn, but relative paths do not.
  • Fix: Reject relative paths (or warn as loudly as the empty-env case) and ensure the helper and parent dir are created 0o600/0o700 before writing the key.
  • Reviewers: Claude

Previously acknowledged (not re-flagged)

  • Legacy sweep omits unbound-setup.py / .last_updated — accepted limitation; inert once settings entries are removed (Greptile, Cursor, maintainer).
  • Env-before---config-dir precedence — accepted design; Claude Code reads the env var alone, so the arg cannot override it without installing where Claude never looks (maintainer).
  • ~/.claude/skills search order / stale-skill preference — intentional; relocated installs resolve from <config dir>/skills, not frozen copies under the old tree (Greptile, tests, maintainer).
  • anthropic_key.sh legacy-sweep ownership guard — fixed in a116048; foreign helpers are no longer deleted (Greptile, maintainer).
  • Tilde / whitespace CLAUDE_CONFIG_DIR handling — fixed; values are read verbatim to match Claude Code (maintainer).
  • .claude.json placement under a relocated dir — resolved empirically against Claude Code 2.1.177; rule is unconditional (maintainer).
  • Install-time vs runtime config-dir mismatch, MDM self-update guard, symlinked hook matching, --config-dir= parsing, clear without persisted env — addressed, warned, or accepted in prior rounds (Cursor, Greptile, maintainer).

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head a1480ca7 · 2026-08-25T16:13Z

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.

4 participants