[BUG] security-guidance plugin: `_glob_match` docstring says `**` matches any depth, but fnmatch implementation silently excludes top-level files from security rules
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Summary
In the security-guidance plugin (verified on 2.0.7, the latest, and 2.0.6; claude-plugins-official marketplace), the path-matching helper for repository-defined security rules — hooks/extensibility.py, _glob_match, lines 247–259 — carries this docstring:
"""Match a path against include/exclude globs. `**` matches any depth."""
The implementation delegates to Python's fnmatch, where ** is not a special token — it is just two *, and since a single * already matches any characters including /, the sequence **/ requires at least one literal / in the matched string. So a rule written as paths: ["**/*.ts"] (or utils/**/*.ts) silently excludes files at the top level of the repo (or of the named directory). The basename fallback in _hit() does not rescue it: the basename contains no / either.
Because .claude/security-patterns.json carries security rules, this is the worst failure mode available: the rules appear installed and active, produce no error, and simply never fire for top-level files. The docstring promises coverage the implementation does not deliver.
We verified this independently in four repositories (three of ours plus an external consumer of the same config shape): every config using the **/ spelling was silently uncovered at top level; rewriting to the single-star form (*.ts, which in fnmatch does cross /) restored coverage, confirmed by firing the rules before/after on top-level files.
What Should Happen?
Expected
Either of these would close the gap honestly:
- implement
**as documented (any depth, including zero directories), or - fix the docstring and (ideally) warn when a pattern contains
**/, since that spelling is a
natural thing to write and currently produces silent non-coverage in a security-relevant file.
Error Messages/Logs
There is no error output — that is the defining property of this bug: a rule that does not
match produces silence, indistinguishable from a rule that passed.
The mismatch is demonstrable in one line against the same fnmatch the plugin calls:
$ python3 -c "import fnmatch; print(fnmatch.fnmatch('config.ts','**/*.ts')); print(fnmatch.fnmatch('src/a.ts','**/*.ts')); print(fnmatch.fnmatch('config.ts','*.ts'))"
False
True
True
Steps to Reproduce
- Enable
security-guidance@claude-plugins-official(2.0.7). - In any repo, create
.claude/security-patterns.json:
```json
{
"patterns": [
{
"rule_name": "demo-glob-gap",
"reminder": "SECURITY DEMO: this reminder should fire for any .ts file at any depth.",
"substrings": ["dangerouslySetInnerHTML"],
"paths": ["**/*.ts"]
}
]
}
- Start a Claude Code session in that repo and edit a TOP-LEVEL file, e.g. ./config.ts, adding the substring dangerouslySetInnerHTML → no reminder fires.
- Make the same edit in ./src/anything.ts → the reminder fires.
- Root cause, directly in the plugin source (hooks/extensibility.py:247-259): _glob_match calls fnmatch.fnmatch(norm, g) / fnmatch.fnmatch(base, g). In fnmatch, **/.ts requires a literal /, so config.ts can never match — while the docstring on line 248 states "* matches any depth". The one-liner in the Logs section shows the mismatch without needing a session.
Claude Model
Opus
Is this a regression?
No, this never worked
Last Working Version
_No response_
Claude Code Version
2.1.229 (Claude Code)
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
PowerShell
Additional Information
- Plugin: security-guidance 2.0.7 (also verified identical in 2.0.6); marketplace `claude-plugins-official`.
- Filing against claude-code since the plugin is distributed via the official marketplace — happy to move this if the plugin source lives elsewhere.
- Verified on Windows 11 (native install, CLI 2.1.220–2.1.229) and by an independent team member on Linux — the defect is in the pattern semantics, not the platform.
- Suggested fix directions are in "What Should Happen": either implement `**` as documented (e.g. translate globs to regex, or use `pathlib.PurePath.full_match` on 3.13+), or correct the docstring and warn on `**/` in a loaded pattern — for a security-rules file, silent non-coverage is the outcome most worth preventing.Showing cached comments. Read the full discussion on GitHub ↗
5 Comments
Confirmed against current
claude-plugins-officialmain (f8f7402b0ff3):_glob_matchstill appliesfnmatchto both the normalized path and basename, and the minimal matrix reproduces the report.There is a second compatibility edge worth pinning before changing it:
fnmatch('src/a.ts', '*.ts')is alsoTrue, because Pythonfnmatchgives/no segment semantics. Meanwhile**/*.tsmissesconfig.ts, andutils/**/*.tsmissesutils/a.tsbut matchesutils/deep/a.ts.I would make the intended contract explicit with one include and exclude matrix before the fix:
*.tsagainst root and nested files (decide whether the current recursive behavior is compatibility or a bug)**/*.tsagainst root, one-level, and deep filesutils/**/*.tsagainst direct children and deeper descendantsThe exclude cases matter independently: a zero-depth miss there turns an intended exclusion into an allow. If current
*.tsrecursion must remain compatible, expanding each**/to also test its zero-directory form is the narrow change; if not, this needs a segment-aware matcher and an explicit behavior change rather than only a leading-**/special case.Thanks for the fast confirmation. One field data point for the "compatibility or bug" question on
*.tsrecursion:After hitting this, our four affected repositories standardized on the single-star spelling (
*.ts) precisely because it crosses/under fnmatch — it was the only spelling that restored full-depth coverage without touching the plugin. So there is real-world config out there (ours, and plausibly anyone else who diagnosed this) that now depends on*being recursive. If the fix gives*segment semantics, those configs go silently uncovered at depth — the same failure class this report is about, inverted.That argues for the narrow change you outlined: expand each
**/to also test its zero-directory form, keeping*recursion as documented-by-behavior. If you do go segment-aware instead, please treat it as a behavior change with a version bump and a loud migration note in the plugin changelog — a security-rules file is the worst place for a silent semantics change, in either direction.On the exclude edge you flagged: agreed it matters independently. For what it's worth, our post-fix configs carry no
**/inexclude_pathseither (we swept include and exclude in the same pass), so the zero-depth-exclude-turns-allow case is live only for configs still on the**/spelling.That deployment evidence resolves the compatibility question for me: recursive
*.tsis now an installed security-policy behavior, even if it began accidentally. I would pin it as a non-regression alongside the fix:*.tsstays matching root/direct/deep, while**/*.tsgains root and retains direct/deep, with the same matrix exercised for includes and excludes. A segment-aware change should be separately versioned and migrated, because narrowing*would silently remove depth coverage. This is stronger than an abstract compatibility concern; I have updated the problem record accordingly.Closed this on the strength of your triage note — re-reading it, it resolves the compatibility
question and updates your problem record, but the fix itself hasn't shipped:
_glob_matchinsecurity-guidance 2.0.7 is identical to 2.0.6 (same docstring, same fnmatch call). Reopening so it
doesn't fall off the queue. If your internal problem record is the real tracking surface, feel free
to close this again — and when a release lands where
**/*.tsmatches a root file while*.tsstays recursive, I can verify it against four real configs and report back.
Reopening is right. My local problem record is evidence and continuity for future validation, not a substitute for this maintainer-facing queue. The release condition you stated is the acceptance criterion I will use:
**/*.tsgains root coverage while*.tsstays recursive, with the same non-regression pinned for includes and excludes. Your four-config check would be useful deployment verification once a candidate release lands.