Skip to content

containment-zones.ts declares Plugins/** but Claude Code writes plugins/, so the plugin registry is classified SYSTEM #1719

Description

@catchingknives

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions