The install-state containment zone in hooks/lib/containment-zones.ts declares its plugin patterns with a capital P:
{
name: "install-state",
patterns: [
"history.jsonl",
"Plugins/**",
"Plugins/installed_plugins.json",
"Plugins/known_marketplaces.json",
"debug/**",
"debug",
],
...
}
Claude Code writes that directory as ~/.claude/plugins/, lowercase. Zone matching is case-sensitive, so none of the three Plugins patterns ever match, and the harness-written plugin registry is classified system instead of user.
Reproduce
On a default macOS install (case-insensitive APFS, which is what makes this easy to miss):
import { classifyTarget } from "./hooks/lib/system-file-guard-core";
classifyTarget(`${CLAUDE_ROOT}/plugins/installed_plugins.json`, CLAUDE_ROOT)
// => { classification: "system", relPath: "plugins/installed_plugins.json" }
classifyTarget(`${CLAUDE_ROOT}/Plugins/installed_plugins.json`, CLAUDE_ROOT)
// => { classification: "user", relPath: "Plugins/installed_plugins.json" }
Those two paths are the same file on a case-insensitive volume. They classify differently because the classifier compares strings while the filesystem does not. readdirSync reports the true on-disk name as plugins, so every real traversal takes the system branch.
The other patterns in the same zone (history.jsonl, debug/**) classify as user correctly, which isolates the fault to the casing rather than to the zone.
Why it matters
plugins/known_marketplaces.json and plugins/installed_plugins.json are written by the harness and contain absolute home-directory paths, for example:
/Users/<user>/.claude/plugins/marketplaces/claude-plugins-official
Two consequences follow from the misclassification:
- Leak-check tooling. Anything that classifies by zone to decide
private-zone versus real-leak will report these harness-generated files as real leaks. They are runtime install state and should be private-zone.
- Write-time guard.
hooks/SystemFileGuard.hook.ts only inspects system-classified targets, so writes into the plugin registry are checked against the identity deny-list. Those files legitimately contain a home path, so the guard is being pointed at a file that can never be clean.
Suggested fix
Lowercase the three patterns:
"plugins/**",
"plugins/installed_plugins.json",
"plugins/known_marketplaces.json",
plugins/** alone covers the two named entries; the explicit lines are redundant either way and could be dropped.
Worth considering alongside it
Since the trigger is a case-sensitivity mismatch that a case-insensitive filesystem hides, the same class of fault can exist anywhere else a zone pattern is spelled by hand. Two options, either of which would prevent a recurrence:
- Match zone patterns case-insensitively, which matches the behaviour of the filesystem the majority of users are on.
- Add a startup or test-time assertion that every literal directory prefix named in
CONTAINMENT_ZONES resolves to a real path with matching case when it exists on disk. That turns a silent misclassification into a loud failure.
I would lean toward the assertion, because case-insensitive matching would make a genuinely distinct Plugins/ directory silently private too.
The
install-statecontainment zone inhooks/lib/containment-zones.tsdeclares its plugin patterns with a capital P:Claude Code writes that directory as
~/.claude/plugins/, lowercase. Zone matching is case-sensitive, so none of the threePluginspatterns ever match, and the harness-written plugin registry is classifiedsysteminstead ofuser.Reproduce
On a default macOS install (case-insensitive APFS, which is what makes this easy to miss):
Those two paths are the same file on a case-insensitive volume. They classify differently because the classifier compares strings while the filesystem does not.
readdirSyncreports the true on-disk name asplugins, so every real traversal takes thesystembranch.The other patterns in the same zone (
history.jsonl,debug/**) classify asusercorrectly, which isolates the fault to the casing rather than to the zone.Why it matters
plugins/known_marketplaces.jsonandplugins/installed_plugins.jsonare written by the harness and contain absolute home-directory paths, for example:Two consequences follow from the misclassification:
private-zoneversusreal-leakwill report these harness-generated files as real leaks. They are runtime install state and should be private-zone.hooks/SystemFileGuard.hook.tsonly inspectssystem-classified targets, so writes into the plugin registry are checked against the identity deny-list. Those files legitimately contain a home path, so the guard is being pointed at a file that can never be clean.Suggested fix
Lowercase the three patterns:
plugins/**alone covers the two named entries; the explicit lines are redundant either way and could be dropped.Worth considering alongside it
Since the trigger is a case-sensitivity mismatch that a case-insensitive filesystem hides, the same class of fault can exist anywhere else a zone pattern is spelled by hand. Two options, either of which would prevent a recurrence:
CONTAINMENT_ZONESresolves to a real path with matching case when it exists on disk. That turns a silent misclassification into a loud failure.I would lean toward the assertion, because case-insensitive matching would make a genuinely distinct
Plugins/directory silently private too.