Fixes#1868 — when CLAUDE_CONFIG_DIR is set to a non-default location
(e.g. ~/.config/claude for XDG compliance, or a multi-tenant install
path), the plugin still wrote state files to the hardcoded ~/.claude/
path, leaving stale state and breaking CLAUDE_CONFIG_DIR's purpose.
Resolution precedence (highest first):
1. SECURITY_WARNINGS_STATE_DIR — plugin-specific override (existing)
2. CLAUDE_CONFIG_DIR/security — CC's config-dir env (new — #1868)
3. ~/.claude/security — default fallback (unchanged)
Empty-string env vars (e.g. CLAUDE_CONFIG_DIR= in a misconfigured
shell) are treated as not-set so the empty path doesn't collide with
os.path.join and silently write to /security at the filesystem root.
Implementation: a single state_dir() helper in _base.py is the source
of truth for resolution. All five modules that previously had inline
SECURITY_WARNINGS_STATE_DIR / ~/.claude/security resolutions
(_base.py, session_state.py, ensure_agent_sdk.py, llm.py, and one
site in security_reminder_hook.py) now call state_dir() instead.
Re-implementing the precedence inline risks drift — one module gets
a future fix, others don't.
The helper is called per-invocation rather than cached at import time
so test monkeypatches of the env vars take effect, and so a long-
running test or future shared-process scenario can change the env
between calls and have the next call observe the new value. The
per-call cost is negligible compared to the subprocess-spawn cost
the hooks pay every fire in production.
Three hardcoded ~/.claude/security strings remain but are NOT
functional resolutions:
- _base.py:39: the fallback BRANCH inside state_dir() itself
- ensure_agent_sdk.py:6, :11: docstring text describing default
location for users
Verified locally on macOS Python 3.13:
- py_compile clean on all 5 modified files.
- Existing 45 smoke + extensibility tests still pass.
- 14 new tests in test_claude_config_dir.py (added to internal test
suite at sg-staging/tests/, not in this PR):
* 7 resolution-semantics: default fallback, CLAUDE_CONFIG_DIR
override, SECURITY_WARNINGS_STATE_DIR beats both, tilde
expansion, empty-string handling (CLAUDE_CONFIG_DIR= must
fall back, NOT join to /security).
* 4 static-shape: each of session_state / ensure_agent_sdk /
llm / security_reminder_hook either imports state_dir from
_base OR has zero resolution patterns. Catches the
regression where someone adds a new state-file writer and
re-implements resolution inline, missing the
CLAUDE_CONFIG_DIR branch.
* 3 end-to-end: with CLAUDE_CONFIG_DIR set, get_state_file /
get_lock_file return paths under <CLAUDE_CONFIG_DIR>/security/;
save_state round-trip writes a file to the redirected path
and re-reads the same contents.
- 59/59 pass total (45 existing + 14 new) in 2.54s.
NOT verified end-to-end with a real CC instance setting
CLAUDE_CONFIG_DIR. The shape tests catch the regression class
(hardcoded ~/.claude/), and the end-to-end test pins the behavior
that user state files actually land at the redirected path.
Closes#1868.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>