[BUG] security-guidance plugin: valid YAML/JSON that is not a mapping (scalar/list) silently drops ALL user security patterns (AttributeError in _load_user_patterns)
Summary
In the official security-guidance plugin (v2.0.7), a security-patterns.{yaml,yml,json} config file whose top-level value is valid YAML/JSON but not a mapping (a bare scalar or a list) raises an uncaught AttributeError inside _load_user_patterns, and — because the caller catches the exception around the whole load — silently discards ALL user-defined security patterns, including well-formed rules already loaded from other config locations (user-level, project, project-local). The only trace is a debug_log line, so with default settings the user's custom security patterns just stop applying with no visible signal.
Note the asymmetry: a file with invalid YAML/JSON is skipped gracefully per-file (_read_config returns None, the loop continues), but a file with valid-but-wrong-shape content aborts the entire load. The failure mode is worse for the less broken input.
Mechanism
hooks/extensibility.py (v2.0.7):
_read_config(line 171) is annotated-> Optional[Dict[str, Any]]but returns whateveryaml.safe_load/json.loadsproduce. A bare scalar (hello) or a list is valid YAML/JSON, so a truthy non-dict comes back.- Line 158:
for entry in (data or {}).get("patterns", []):— thedata or {}guard handles falsy values (None,'',{}), but a truthy non-dict passes through and.getraisesAttributeError. load_for_session(lines 72–76) wraps the call intry/except Exceptionand resets_user_patterns = [], so one malformed-shape file in any of the config locations wipes the rules from all of them.
Reproduction (plugin code executed as-is)
import os, sys, tempfile
sys.path.insert(0, os.path.expanduser(
"~/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.7/hooks"))
import extensibility as ext
tmp = tempfile.mkdtemp(); cfg = os.path.join(tmp, ".claude"); os.makedirs(cfg)
# A VALID project file with one well-formed pattern...
with open(os.path.join(cfg, "security-patterns.yaml"), "w") as f:
f.write("patterns:\n - rule_name: demo-rule\n reminder: 'AWS access key detected'\n regex: 'AKIA[0-9A-Z]{16}'\n")
# ...plus a .local file whose YAML parses to a SCALAR (valid YAML, wrong shape).
with open(os.path.join(cfg, "security-patterns.local.yaml"), "w") as f:
f.write("just a bare scalar\n")
ext._load_user_patterns(tmp) # -> AttributeError: 'str' object has no attribute 'get' (line 158)
ext.load_for_session(tmp)
ext.user_patterns() # -> [] (the valid demo-rule is gone too)
# Control: remove the scalar file, reload:
os.remove(os.path.join(cfg, "security-patterns.local.yaml"))
ext.load_for_session(tmp)
ext.user_patterns() # -> [{'ruleName': 'user:demo-rule', ...}] (loads fine)
Observed output:
--- 1) direct call: _load_user_patterns raises ---
for entry in (data or {}).get("patterns", []):
AttributeError: 'str' object has no attribute 'get'
--- 2) via load_for_session: exception caught, ALL patterns dropped ---
user_patterns() == []
--- 3) control: same tree WITHOUT the scalar file loads fine ---
user_patterns() names == ['user:demo-rule']
The JSON path has the same defect — a top-level array is valid JSON and truthy:
security-patterns.json containing: [{"rule_name": "x", "reminder": "r", "regex": "a"}]
-> AttributeError: 'list' object has no attribute 'get'
(A plausible way for a user to hit this: writing the patterns: list itself at the top level of the file, without the patterns: key — the config then parses to a list.)
Suggested fix
Treat a non-dict top-level value the same way malformed input is already treated — skip that file and continue, e.g. in _read_config return None (with the existing debug_log) when the parsed value is not a dict. That matches the function's own type annotation and keeps one bad file from wiping rules loaded from other locations.
Environment
- Plugin:
claude-plugins-official/security-guidance2.0.7 (marketplace-current) - Claude Code CLI: 2.1.232
- Python 3.12.10, PyYAML 6.0.3
- Windows 11 (defect is platform-independent; the failing line is plain Python)
Found while building a read-only conformance check that validates our repos' security-patterns configs against the loader's real behavior (executed, not inferred). Unrelated to #86545 (fnmatch depth matching), which we're tracking separately.
4 Comments
Confirmed / reproduced with security-guidance 2.0.7 (installed from claude-plugins-official via Claude Code 2.1.234, Linux, Python 3.12, PyYAML 6.0.1), driving the plugin's PostToolUse hook directly with an Edit payload:
.claude/security-patterns.yamlwith one valid rule → an edit matching its regex produces the custom reminder..claude/security-patterns.local.yamlcontaining justjust a bare scalar→ the hook emits nothing for the same edit: the valid rule from the other file is gone too. Same result when the local file is a top-level YAML list, or a.local.jsonthat is a top-level array.AttributeError: 'str' object has no attribute 'get'; via the session load path it is swallowed and the user pattern list ends up empty.🤖 Generated with Claude Code
---
_Generated by Claude Code_
Implemented the suggested fix — full change here:
432ae60fix/security-guidance-nonmapping-config_read_confignow assigns the parsed result todataand returnsNone(with adebug_log) when it isn't adict, so a scalar/list top-level value is skipped the same way malformed input already is:Verified with the repro:
security-patterns.local.yamlcontaining a bare scalar andsecurity-patterns.local.jsoncontaining a list are both skipped, while the validdemo-rulein the committedsecurity-patterns.yamlsurvives —user_patterns()returns['user:demo-rule']instead of[].I couldn't open a PR (this repository restricts PRs to collaborators), so leaving the branch/commit here in case a maintainer wants to pick it up.
Also hit the collaborators-only PR restriction, so leaving a fix here too.
Same root cause as the fix already posted above (
_read_configneeds to reject a non-dict parse result), plus a test file since that one didn't come with tests:_read_confignow checksisinstance(parsed, dict)for both the JSON and YAML branches and skips just that file (with a debug_log) when it isn't, matching the function's existingOptional[Dict[str, Any]]contract instead of letting.get()blow up downstream in_load_user_patterns.Added
plugins/security-guidance/hooks/test_extensibility.py(stdlibunittest, no extra deps) covering_read_configon valid/invalid/wrong-shape YAML and JSON, plus an end-to-end repro: a valid project-level pattern next to a.local.yamlthat parses to a bare scalar, asserting the valid pattern survives throughload_for_session/user_patterns(). Checked it fails with the reportedAttributeErroragainst the current code and passes clean with the fix.Happy to open this as an actual PR if someone can add me as a collaborator, otherwise a maintainer's welcome to cherry-pick straight from the branch.
Branch with a fix:
Diff: https://github.com/anthropics/claude-code/compare/main...Malwurf:claude-code:fix/security-patterns-non-mapping
Commit: https://github.com/Malwurf/claude-code/commit/d0ad0d7
Fixed at the layer the report points at —
_read_configenforces the contract its own-> Optional[Dict[str, Any]]annotation already claims, so a wrong-shaped file is skipped per-file exactly like a malformed one, restoring the symmetry the issue notes:This matches the convention already used elsewhere in the same plugin —
session_state.py:93-96guards its parsed JSON withisinstancebefore use.One variant the issue does not cover
While building the repro I hit a second shape with the same blast radius:
{"patterns": 5}is a mapping, so it passes the top-level check, butfor entry in 5raisesTypeErrorand wipes everything the same way. Fixed alongside, since it is the same defect one level down:Verification
Harness imports the plugin's
extensibility.pyas-is, pointsHOMEat a throwaway dir so the real~/.claudeis untouched, writes a good project-scope config plus one project-local file under test, then asserts what survivesload_for_session().Before — 5/9:
After — 9/9, identical results for
.jsonand for.yaml(PyYAML 6.0.3 in a venv):No working configuration changes behaviour: every shape now skipped previously raised, so it never loaded rules in the first place. The format is undocumented outside the module docstring, and the only shape the code ever read is a mapping with a
patternskey.On tests
The repo has no test infrastructure at all — no test files, no runner, and the one workflow triggered on
pull_requestis path-limited to.github/**. So I did not add a test file, and this harness lives outside the diff. If you'd like a small framework-free harness for thesecurity-guidancehooks (plainpython3, no dependencies, runnable as one script), I'm glad to contribute one — but where it should live and whether anything runs it is your architectural call, not mine. Say the word and I'll write it.I could not open a pull request:
CreatePullRequestreturnsMalwurf does not have the correct permissions. Cross-fork PRs from outside accounts appear to have stopped on this repo after 2026-08-16 (see also #90065 and #83802). Pull from the branch above as you prefer.