anthropics / anthropics/claude-plugins-official

security-guidance: user-level claude-security-guidance.md and security-patterns.yaml are never discovered when CLAUDE_CONFIG_DIR is set (#1868 fixed only for state files)

Open
#4,826 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
36.3k
Forks
4.1k
Avg merge
2d 14h
Merged PRs (30d)
539

Description

## Summary

The `security-guidance` plugin discovers its user-level config files at a hardcoded `~/.claude/`, ignoring `CLAUDE_CONFIG_DIR`. On any install that sets `CLAUDE_CONFIG_DIR` to something other than `~/.claude`, the user-level `claude-security-guidance.md` and `security-patterns.{yaml,json}` are silently never loaded. There is no warning, and the debug log shows no entry, so the policy simply has no effect.

This is the half of #1868 that was never fixed. Commit `0d22ba35` ("security-guidance: respect CLAUDE_CONFIG_DIR for plugin state files") scoped itself to state files and touched five modules — `_base.py`, `session_state.py`, `ensure_agent_sdk.py`, `llm.py`, `security_reminder_hook.py`. `extensibility.py` was not one of them, although it had existed since `0bde1686` three days earlier, and it has never been modified since. Its literal `~/.claude` does not contain the `security` segment the sweep looked for, so it did not match.

That commit message closes with: "Re-implementing the precedence inline risks drift — one module gets a future fix, others don't." That is exactly what happened, in the one module the sweep skipped. The two halves of the plugin now disagree with each other:

- `hooks/_base.py:33-39` reads `CLAUDE_CONFIG_DIR` and falls back to `~/.claude/security`.
- `hooks/extensibility.py:96` hardcodes `os.path.expanduser(os.path.join("~", ".claude", basename))` and reads no environment variable at all (`grep -n "environ\|getenv" extensibility.py` returns nothing).

Net effect on my machine: the plugin writes its log to `~/.claude-work/security/log.txt` (honoring `CLAUDE_CONFIG_DIR`) while looking for my policy file in `~/.claude/`, a directory that does not exist here at all.

## Environment

- Claude Code CLI: `2.1.220`
- Plugin: `security-guidance` `2.0.6` (from `claude-plugins-official`)
- Provider setup: 1P Anthropic subscription. No `ANTHROPIC_BASE_URL`, no Bedrock/Vertex env vars set.
- OS: macOS 26.5.2 (Darwin 25.5.0), arm64
- Python: 3.14.6
- `CLAUDE_CONFIG_DIR=~/.claude-work` (exported machine-wide from `~/.zshenv`; `~/.claude` does not exist)

## Repro

This runs against an isolated temporary `HOME`, so it does not touch your real config.

```bash
#!/usr/bin/env bash
set -euo pipefail

# Adjust to your install. The path itself illustrates the bug: this machine's
# config dir is ~/.claude-work, so the plugin cache lives there.
HOOKS=~/.claude-work/plugins/marketplaces/claude-plugins-official/plugins/security-guidance/hooks

H=$(mktemp -d "${TMPDIR:-/tmp}/sg-repro.XXXXXX")
mkdir -p "$H/.claude-work" "$H/.claude"
printf '# test policy\n- MARKER_POLICY_LINE\n' > "$H/.claude-work/claude-security-guidance.md"

probe() {
python3 -c "
import sys; sys.path.insert(0, '$HOOKS')
import extensibility as e
e.load_for_session(None)
print(' policy loaded:', 'MARKER_POLICY_LINE' in (e._guidance_block or ''))
"
}

echo "case 1: policy at \$CLAUDE_CONFIG_DIR/claude-security-guidance.md (no ~/.claude)"
HOME="$H" CLAUDE_CONFIG_DIR="$H/.claude-work" probe

echo "case 2: same file moved to ~/.claude/claude-security-guidance.md"
mv "$H/.claude-work/claude-security-guidance.md" "$H/.claude/claude-security-guidance.md"
HOME="$H" CLAUDE_CONFIG_DIR="$H/.claude-work" probe

rm -rf "$H"
```

**Actual:**

```
case 1: policy at $CLAUDE_CONFIG_DIR/claude-security-guidance.md (no ~/.claude)
policy loaded: False
case 2: same file moved to ~/.claude/claude-security-guidance.md
policy loaded: True
```

**Expected:** case 1 loads the policy, because `CLAUDE_CONFIG_DIR` is where this install keeps its Claude Code config.

## Log

`~/.claude-work/security/log.txt` — note that the log lives under `CLAUDE_CONFIG_DIR`, which is exactly the path `extensibility.py` declines to consult:

```
[...] Processing: hook_event=UserPromptSubmit, tool=
[...] Captured git baseline:
[...] Processing: hook_event=PostToolUse, tool=Bash
[...] Processing: hook_event=Stop, tool=
[...] Stop hook: empty review set
```

`grep extensibility log.txt` is empty across the whole session. The `extensibility: loaded N chars from ` debug line at `extensibility.py:115` can never fire for a user-level file on this install, since the only path it probes does not exist.

## Suggested fix

Mirror what `_base.py` already does, and probe both locations so existing `~/.claude` users are unaffected:

```python
def _user_config_dirs() -> List[str]:
"""User-level config dirs, in probe order. CLAUDE_CONFIG_DIR first when set;
~/.claude always, so existing installs keep working."""
dirs = []
explicit = os.environ.get("CLAUDE_CONFIG_DIR")
if explicit and explicit.strip():
dirs.append(os.path.expanduser(explicit.strip()))
default = os.path.expanduser(os.path.join("~", ".claude"))
if default not in dirs:
dirs.append(default)
return dirs

def _config_paths(cwd: Optional[str], basename: str) -> List[Tuple[str, str]]:
paths = [("User", os.path.join(d, basename)) for d in _user_config_dirs()]
if cwd:
paths.append(("Project", os.path.join(cwd, ".claude", basename)))
stem, ext = os.path.splitext(basename)
paths.append(("Project (local)", os.path.join(cwd, ".claude", f"{stem}.local{ext}")))
return paths
```

Since `_config_paths` serves both config files, this fixes `claude-security-guidance.md` and `security-patterns.{yaml,json}` together. Both remain additive-only, so the trust model in the `extensibility.py` docstring is unchanged. Empty-string handling matches `_base.state_dir`, so `CLAUDE_CONFIG_DIR=` with no value falls through to `~/.claude` instead of probing the filesystem root.

A smaller alternative, if probing two directories is unwelcome: keep one path but resolve it through the same helper `_base.py` uses, and log a debug line when a user-level file is found in neither location.

## Docs affected by the same fix

The README still documents the hardcoded literal in four places, and two of them are already wrong today, because the log path *does* honor `CLAUDE_CONFIG_DIR`:

- line 66 — `~/.claude/claude-security-guidance.md` as the user-wide location
- line 94 — "writes its own debug log to `~/.claude/security/log.txt`"
- line 102 — same log path, under Troubleshooting
- line 116 — "the relevant section of `~/.claude/security/log.txt`", under Reporting issues

The docstring at `extensibility.py:13-19` says the same thing, including "Org admins can still push files to `~/.claude/` via MDM/GPO" — which is exactly the deployment this bug breaks.

## Why this matters

The failure is silent and it disables a security control. Anyone running multiple Claude Code profiles via `CLAUDE_CONFIG_DIR` will write an org policy file, see the plugin working (pattern warnings and LLM review still run), and reasonably conclude their policy is in force when it is not. A one-line debug message on "no user-level guidance found" would also have made this diagnosable without reading the source.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with hooks/extensibility.py and compare its user-config path handling with hooks/_base.py, then run the supplied isolated-HOME reproduction script. Check the README references to user config and log paths. Done means claude-security-guidance.md and security-patterns.yaml/json are discovered through CLAUDE_CONFIG_DIR while existing ~/.claude and project paths continue to work, with the affected documentation corrected.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
documentation, security, tooling
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.