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

Status Open
Reported on v2.1.232
Maintainer reply ✓ Yes — claude[bot]
Activity 4 comments · opened Aug 18, 2026
💡 Likely answer: A maintainer (claude[bot], contributor) responded on this thread — see the highlighted reply below.

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 whatever yaml.safe_load / json.loads produce. 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", []): — the data or {} guard handles falsy values (None, '', {}), but a truthy non-dict passes through and .get raises AttributeError.
  • load_for_session (lines 72–76) wraps the call in try/except Exception and 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-guidance 2.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.

View original on GitHub ↗

4 Comments

claude[bot] contributor · 12 days ago

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:

  • Project .claude/security-patterns.yaml with one valid rule → an edit matching its regex produces the custom reminder.
  • Add .claude/security-patterns.local.yaml containing just just 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.json that is a top-level array.
  • Replace the local file with syntactically invalid YAML → the valid rule fires again (only that file is skipped), so the well-formed-but-non-mapping case is handled worse than the malformed one, exactly as described.
  • Calling the loader directly raises 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_

see-stack · 9 days ago

Implemented the suggested fix — full change here:

_read_config now assigns the parsed result to data and returns None (with a debug_log) when it isn't a dict, so a scalar/list top-level value is skipped the same way malformed input already is:

    if not isinstance(data, dict):
        debug_log(
            f"extensibility: skipping {path}: expected a mapping of patterns, "
            f"got {type(data).__name__}"
        )
        return None
    return data

Verified with the repro: security-patterns.local.yaml containing a bare scalar and security-patterns.local.json containing a list are both skipped, while the valid demo-rule in the committed security-patterns.yaml survives — 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.

cr-sbarbouche · 8 days ago

Also hit the collaborators-only PR restriction, so leaving a fix here too.

Same root cause as the fix already posted above (_read_config needs to reject a non-dict parse result), plus a test file since that one didn't come with tests:

_read_config now checks isinstance(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 existing Optional[Dict[str, Any]] contract instead of letting .get() blow up downstream in _load_user_patterns.

Added plugins/security-guidance/hooks/test_extensibility.py (stdlib unittest, no extra deps) covering _read_config on valid/invalid/wrong-shape YAML and JSON, plus an end-to-end repro: a valid project-level pattern next to a .local.yaml that parses to a bare scalar, asserting the valid pattern survives through load_for_session/user_patterns(). Checked it fails with the reported AttributeError against 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.

Malwurf · 3 days ago

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_config enforces 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:

if not isinstance(data, dict):
    debug_log(f"extensibility: skipping {path}: expected a mapping, got {type(data).__name__}")
    return None

This matches the convention already used elsewhere in the same plugin — session_state.py:93-96 guards its parsed JSON with isinstance before 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, but for entry in 5 raises TypeError and wipes everything the same way. Fixed alongside, since it is the same defect one level down:

entries = data.get("patterns", [])
if not isinstance(entries, list):
    debug_log(f"extensibility: skipping {candidate}: 'patterns' must be a list, got {type(entries).__name__}")
    entries = []

Verification

Harness imports the plugin's extensibility.py as-is, points HOME at a throwaway dir so the real ~/.claude is untouched, writes a good project-scope config plus one project-local file under test, then asserts what survives load_for_session().

Before — 5/9:

project-local file     | expected | got  | result
bare scalar            |    1     |  0   | FAIL  []
list                   |    1     |  0   | FAIL  []
number                 |    1     |  0   | FAIL  []
patterns: not-a-list   |    1     |  0   | FAIL  []
invalid JSON           |    1     |  1   | PASS  ['user:demo-rule']
empty file             |    1     |  1   | PASS  ['user:demo-rule']
empty mapping {}       |    1     |  1   | PASS  ['user:demo-rule']
VALID extra rule       |    2     |  2   | PASS  ['user:demo-rule', 'user:extra-rule']
(control) no local     |    1     |  1   | PASS  ['user:demo-rule']

After — 9/9, identical results for .json and for .yaml (PyYAML 6.0.3 in a venv):

bare scalar / list / number / patterns-not-a-list  ->  PASS  ['user:demo-rule']
VALID extra rule                                   ->  PASS  ['user:demo-rule', 'user:extra-rule']

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 patterns key.

On tests

The repo has no test infrastructure at all — no test files, no runner, and the one workflow triggered on pull_request is 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 the security-guidance hooks (plain python3, 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: CreatePullRequest returns Malwurf 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.