claude-code - 💡(How to fix) Fix [BUG] [Windows] Plugin install fails with EPERM during rename to versioned cache slot — Defender holds handles on freshly-extracted files [2 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#54053Fetched 2026-04-28 06:40:31
View on GitHub
Comments
2
Participants
2
Timeline
6
Reactions
0
Timeline (top)
labeled ×4commented ×2

/plugin install <name> fails with EPERM: operation not permitted, rename when Claude Code attempts to move a freshly-extracted plugin from its temp location to its versioned cache slot. The destination directory does not exist or is empty before the rename — this is a fresh install on a clean cache, not a half-completed retry.

The reproducible pattern: every install of a fresh plugin EPERMs on first attempt. The orphan extract at cache/<plugin>/ remains; the empty target at cache/<marketplace>/<plugin>/ is left behind. Subsequent retries fail identically until the orphan is cleared. After clearing, the next install reproduces the same failure.

Adding ~/.claude/plugins to Windows Defender's exclusion list completely resolves the issue — installs succeed on first attempt with no other changes. This strongly indicates Defender's real-time scanning is holding file handles on the freshly-written plugin tree at the moment fs.rename() is attempted.

This is the same root cause as #52435's "Bug 2" (Windows fs.rename() limitation) but on a different code path — /plugin install rather than /plugin marketplace add. Also distinct from #46830, which is specific to .bak directory loops.

Error Message

#!/usr/bin/env python3 """ Rescue a plugin install that EPERM-failed during the rename to its versioned slot. Re-fetches the plugin source directly from the marketplace catalog, places it in the correct cache slot using copy (not rename), and registers the install in ~/.claude/plugins/installed_plugins.json.

SECURITY MODEL

Every destructive operation (rmtree, copytree-from-untrusted) is gated by two independent checks:

(1) Input validation rejects path-unsafe segment values from CLI args and untrusted JSON content (plugin/marketplace/version names). (2) safe_rmtree / containment guards refuse to operate on any resolved path that does not live strictly under the expected root directory.

Both must pass for the operation to proceed. A bug in one is mitigated by the other.

Usage: python rescue_install.py <plugin>@<marketplace> # dry-run python rescue_install.py <plugin>@<marketplace> --apply # execute """ import json import os import shutil import subprocess import sys import tempfile from datetime import datetime, timezone

home = os.path.expanduser('~') plugins_root = os.path.join(home, '.claude', 'plugins') marketplaces_root = os.path.join(plugins_root, 'marketplaces') cache_root = os.path.join(plugins_root, 'cache') installed_file = os.path.join(plugins_root, 'installed_plugins.json')

HARDENING (1): segment validation.

def _validate_segment(value, kind): if not isinstance(value, str) or not value: raise ValueError(f'{kind} must be a non-empty string') if value in ('.', '..'): raise ValueError(f'{kind} {value!r} is a parent-directory reference') if '/' in value or '\' in value: raise ValueError(f'{kind} {value!r} contains a path separator') if os.path.isabs(value): raise ValueError(f'{kind} {value!r} is an absolute path') if value.startswith('.'): raise ValueError(f'{kind} {value!r} starts with a dot') return value

HARDENING (2): containment check.

def _safe_under(path, root): real_path = os.path.realpath(path) real_root = os.path.realpath(root) try: common = os.path.commonpath([real_path, real_root]) except ValueError: raise RuntimeError(f'path {path!r} not under {root!r} (different roots)') if common != real_root: raise RuntimeError(f'refusing op on {real_path!r}; not under {real_root!r}') return real_path

HARDENING (3): rmtree wrapper.

def safe_rmtree(path, root): if not os.path.isdir(path): return _safe_under(path, root) shutil.rmtree(path)

def parse_args(argv): apply_changes = '--apply' in argv positional = [a for a in argv[1:] if not a.startswith('--')] if len(positional) != 1 or '@' not in positional[0]: print('usage: rescue_install.py <plugin>@<marketplace> [--apply]') sys.exit(2) plugin_name, marketplace = positional[0].split('@', 1) _validate_segment(plugin_name, 'plugin name') _validate_segment(marketplace, 'marketplace name') return plugin_name, marketplace, apply_changes

def read_marketplace_catalog(marketplace): path = os.path.join(marketplaces_root, marketplace, '.claude-plugin', 'marketplace.json') if not os.path.isfile(path): print(f'marketplace not registered locally: {path}') sys.exit(1) with open(path) as f: return json.load(f), path

def find_plugin_entry(catalog, plugin_name): for entry in catalog.get('plugins', []): if entry.get('name') == plugin_name: return entry return None

def run(cmd, cwd=None): proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) if proc.returncode != 0: raise RuntimeError(f'{" ".join(cmd)} failed:\n{proc.stderr}') return proc.stdout

def fetch_source_to(entry, marketplace, dest_dir): source = entry.get('source')

if isinstance(source, str):
    marketplace_root = os.path.join(marketplaces_root, marketplace)
    src = os.path.normpath(os.path.join(marketplace_root, source))
    # HARDENING (4): source-path containment.
    _safe_under(src, marketplace_root)
    if not os.path.isdir(src):
        raise RuntimeError(f'relative source not found: {src}')
    shutil.copytree(src, dest_dir, ignore=shutil.ignore_patterns('.git'))
    return _version_from(entry, dest_dir)

stype = source.get('source')
if stype == 'github':
    url = f'https://github.com/{source["repo"]}.git'
    ref = source.get('ref')
    with tempfile.TemporaryDirectory(prefix='rescue_') as td:
        clone_to = os.path.join(td, 'clone')
        args = ['git', 'clone', '--depth', '1']
        if ref:
            args += ['--branch', ref]
        args += [url, clone_to]
        run(args)
        shutil.copytree(clone_to, dest_dir, ignore=shutil.ignore_patterns('.git'))
    return _version_from(entry, dest_dir)

if stype == 'git-subdir':
    url = source['url']
    path_in_repo = source['path']
    ref = source.get('ref')
    with tempfile.TemporaryDirectory(prefix='rescue_') as td:
        clone_to = os.path.join(td, 'clone')
        args = ['git', 'clone', '--depth', '1', '--filter=blob:none', '--sparse']
        if ref:
            args += ['--branch', ref]
        args += [url, clone_to]
        run(args)
        run(['git', 'sparse-checkout', 'set', path_in_repo], cwd=clone_to)
        sub = os.path.join(clone_to, path_in_repo)
        if not os.path.isdir(sub):
            raise RuntimeError(f'subdir {path_in_repo} not present after sparse checkout')
        shutil.copytree(sub, dest_dir, ignore=shutil.ignore_patterns('.git'))
    return _version_from(entry, dest_dir)

raise RuntimeError(f'unsupported source type: {stype!r}')

def _version_from(entry, plugin_dir): pj = os.path.join(plugin_dir, '.claude-plugin', 'plugin.json') if os.path.isfile(pj): try: with open(pj) as f: v = json.load(f).get('version') if v: _validate_segment(v, 'version (from plugin.json)') return v except ValueError: raise except Exception: pass fallback = entry.get('version') or 'unknown' _validate_segment(fallback, 'version (from marketplace.json or fallback)') return fallback

def register_install(plugin_name, marketplace, install_path, version): try: with open(installed_file) as f: data = json.load(f) except FileNotFoundError: data = {'version': 2, 'plugins': {}} if 'plugins' not in data: data['plugins'] = {}

now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')[:-4] + 'Z'
key = f'{plugin_name}@{marketplace}'
record = {
    'scope': 'user',
    'installPath': install_path,
    'version': version,
    'installedAt': now,
    'lastUpdated': now,
}
existing = data['plugins'].get(key, [])
existing = [r for r in existing if r.get('scope') != 'user']
existing.append(record)
data['plugins'][key] = existing

with open(installed_file, 'w') as f:
    json.dump(data, f, indent=2)

def main(argv): plugin_name, marketplace, apply_changes = parse_args(argv)

catalog, catalog_path = read_marketplace_catalog(marketplace)
entry = find_plugin_entry(catalog, plugin_name)
if entry is None:
    print(f'plugin {plugin_name!r} not found in {catalog_path}')
    return 1

declared_version = entry.get('version')
if declared_version is not None:
    _validate_segment(declared_version, 'version (from marketplace entry)')

version = declared_version or '<from plugin.json after fetch>'
target = os.path.join(cache_root, marketplace, plugin_name, version)
print(f'plan: rescue {plugin_name}@{marketplace}')
print(f'  source: {entry.get("source")}')
print(f'  target: {target}')

if not apply_changes:
    print('\nRe-run with --apply to fetch and install.')
    return 0

orphan = os.path.join(cache_root, plugin_name)
if os.path.isdir(orphan):
    contents = os.listdir(orphan)
    if any(os.path.isfile(os.path.join(orphan, c)) for c in contents):
        print(f'  removing orphan extract: {orphan}')
        safe_rmtree(orphan, cache_root)

if os.path.isdir(target):
    print(f'  removing existing target: {target}')
    safe_rmtree(target, cache_root)

os.makedirs(os.path.dirname(target), exist_ok=True)

fetch_temp = target + '.fetching'
if os.path.exists(fetch_temp):
    safe_rmtree(fetch_temp, cache_root)
try:
    version = fetch_source_to(entry, marketplace, fetch_temp)
    retargeted = os.path.join(cache_root, marketplace, plugin_name, version)
    if retargeted != target:
        os.makedirs(os.path.dirname(retargeted), exist_ok=True)
        target = retargeted
    if os.path.isdir(target):
        safe_rmtree(target, cache_root)
    shutil.copytree(fetch_temp, target)
finally:
    if os.path.isdir(fetch_temp):
        try:
            safe_rmtree(fetch_temp, cache_root)
        except Exception:
            pass

register_install(plugin_name, marketplace, target, version)
print(f'\ninstalled: {plugin_name}@{marketplace} v{version}')
print(f'  at: {target}')
print('\nRestart Claude Code so installed_plugins.json is reloaded.')
return 0

if name == 'main': sys.exit(main(sys.argv))

Root Cause

This is the same root cause as #52435's "Bug 2" (Windows fs.rename() limitation) but on a different code path — /plugin install rather than /plugin marketplace add. Also distinct from #46830, which is specific to .bak directory loops.

Fix Action

Fix / Workaround

Workaround A — Windows Defender exclusion (with caveats)

Workaround B — User-space rescue script (no admin, no AV trade-off)

Neither workaround is a substitute for the upstream fix. The actual resolution is the rename-utility change suggested below (retry-on-EPERM or copy-delete fallback).

Code Example

Failed to install: EPERM: operation not permitted, rename
'C:\Users\<user>\.claude\plugins\cache\<plugin>' ->
'C:\Users\<user>\.claude\plugins\cache\<marketplace>\<plugin>\<version>'

---

Add-MpPreference -ExclusionPath "$env:USERPROFILE\.claude\plugins"

---

#!/usr/bin/env python3
"""
Rescue a plugin install that EPERM-failed during the rename to its versioned
slot. Re-fetches the plugin source directly from the marketplace catalog,
places it in the correct cache slot using copy (not rename), and registers
the install in ~/.claude/plugins/installed_plugins.json.

SECURITY MODEL
--------------
Every destructive operation (rmtree, copytree-from-untrusted) is gated by
two independent checks:

  (1) Input validation rejects path-unsafe segment values from CLI args
      and untrusted JSON content (plugin/marketplace/version names).
  (2) safe_rmtree / containment guards refuse to operate on any resolved
      path that does not live strictly under the expected root directory.

Both must pass for the operation to proceed. A bug in one is mitigated by
the other.

Usage:
  python rescue_install.py <plugin>@<marketplace>             # dry-run
  python rescue_install.py <plugin>@<marketplace> --apply     # execute
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone

home = os.path.expanduser('~')
plugins_root = os.path.join(home, '.claude', 'plugins')
marketplaces_root = os.path.join(plugins_root, 'marketplaces')
cache_root = os.path.join(plugins_root, 'cache')
installed_file = os.path.join(plugins_root, 'installed_plugins.json')


# HARDENING (1): segment validation.
def _validate_segment(value, kind):
    if not isinstance(value, str) or not value:
        raise ValueError(f'{kind} must be a non-empty string')
    if value in ('.', '..'):
        raise ValueError(f'{kind} {value!r} is a parent-directory reference')
    if '/' in value or '\\' in value:
        raise ValueError(f'{kind} {value!r} contains a path separator')
    if os.path.isabs(value):
        raise ValueError(f'{kind} {value!r} is an absolute path')
    if value.startswith('.'):
        raise ValueError(f'{kind} {value!r} starts with a dot')
    return value


# HARDENING (2): containment check.
def _safe_under(path, root):
    real_path = os.path.realpath(path)
    real_root = os.path.realpath(root)
    try:
        common = os.path.commonpath([real_path, real_root])
    except ValueError:
        raise RuntimeError(f'path {path!r} not under {root!r} (different roots)')
    if common != real_root:
        raise RuntimeError(f'refusing op on {real_path!r}; not under {real_root!r}')
    return real_path


# HARDENING (3): rmtree wrapper.
def safe_rmtree(path, root):
    if not os.path.isdir(path):
        return
    _safe_under(path, root)
    shutil.rmtree(path)


def parse_args(argv):
    apply_changes = '--apply' in argv
    positional = [a for a in argv[1:] if not a.startswith('--')]
    if len(positional) != 1 or '@' not in positional[0]:
        print('usage: rescue_install.py <plugin>@<marketplace> [--apply]')
        sys.exit(2)
    plugin_name, marketplace = positional[0].split('@', 1)
    _validate_segment(plugin_name, 'plugin name')
    _validate_segment(marketplace, 'marketplace name')
    return plugin_name, marketplace, apply_changes


def read_marketplace_catalog(marketplace):
    path = os.path.join(marketplaces_root, marketplace, '.claude-plugin', 'marketplace.json')
    if not os.path.isfile(path):
        print(f'marketplace not registered locally: {path}')
        sys.exit(1)
    with open(path) as f:
        return json.load(f), path


def find_plugin_entry(catalog, plugin_name):
    for entry in catalog.get('plugins', []):
        if entry.get('name') == plugin_name:
            return entry
    return None


def run(cmd, cwd=None):
    proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
    if proc.returncode != 0:
        raise RuntimeError(f'{" ".join(cmd)} failed:\n{proc.stderr}')
    return proc.stdout


def fetch_source_to(entry, marketplace, dest_dir):
    source = entry.get('source')

    if isinstance(source, str):
        marketplace_root = os.path.join(marketplaces_root, marketplace)
        src = os.path.normpath(os.path.join(marketplace_root, source))
        # HARDENING (4): source-path containment.
        _safe_under(src, marketplace_root)
        if not os.path.isdir(src):
            raise RuntimeError(f'relative source not found: {src}')
        shutil.copytree(src, dest_dir, ignore=shutil.ignore_patterns('.git'))
        return _version_from(entry, dest_dir)

    stype = source.get('source')
    if stype == 'github':
        url = f'https://github.com/{source["repo"]}.git'
        ref = source.get('ref')
        with tempfile.TemporaryDirectory(prefix='rescue_') as td:
            clone_to = os.path.join(td, 'clone')
            args = ['git', 'clone', '--depth', '1']
            if ref:
                args += ['--branch', ref]
            args += [url, clone_to]
            run(args)
            shutil.copytree(clone_to, dest_dir, ignore=shutil.ignore_patterns('.git'))
        return _version_from(entry, dest_dir)

    if stype == 'git-subdir':
        url = source['url']
        path_in_repo = source['path']
        ref = source.get('ref')
        with tempfile.TemporaryDirectory(prefix='rescue_') as td:
            clone_to = os.path.join(td, 'clone')
            args = ['git', 'clone', '--depth', '1', '--filter=blob:none', '--sparse']
            if ref:
                args += ['--branch', ref]
            args += [url, clone_to]
            run(args)
            run(['git', 'sparse-checkout', 'set', path_in_repo], cwd=clone_to)
            sub = os.path.join(clone_to, path_in_repo)
            if not os.path.isdir(sub):
                raise RuntimeError(f'subdir {path_in_repo} not present after sparse checkout')
            shutil.copytree(sub, dest_dir, ignore=shutil.ignore_patterns('.git'))
        return _version_from(entry, dest_dir)

    raise RuntimeError(f'unsupported source type: {stype!r}')


def _version_from(entry, plugin_dir):
    pj = os.path.join(plugin_dir, '.claude-plugin', 'plugin.json')
    if os.path.isfile(pj):
        try:
            with open(pj) as f:
                v = json.load(f).get('version')
                if v:
                    _validate_segment(v, 'version (from plugin.json)')
                    return v
        except ValueError:
            raise
        except Exception:
            pass
    fallback = entry.get('version') or 'unknown'
    _validate_segment(fallback, 'version (from marketplace.json or fallback)')
    return fallback


def register_install(plugin_name, marketplace, install_path, version):
    try:
        with open(installed_file) as f:
            data = json.load(f)
    except FileNotFoundError:
        data = {'version': 2, 'plugins': {}}
    if 'plugins' not in data:
        data['plugins'] = {}

    now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')[:-4] + 'Z'
    key = f'{plugin_name}@{marketplace}'
    record = {
        'scope': 'user',
        'installPath': install_path,
        'version': version,
        'installedAt': now,
        'lastUpdated': now,
    }
    existing = data['plugins'].get(key, [])
    existing = [r for r in existing if r.get('scope') != 'user']
    existing.append(record)
    data['plugins'][key] = existing

    with open(installed_file, 'w') as f:
        json.dump(data, f, indent=2)


def main(argv):
    plugin_name, marketplace, apply_changes = parse_args(argv)

    catalog, catalog_path = read_marketplace_catalog(marketplace)
    entry = find_plugin_entry(catalog, plugin_name)
    if entry is None:
        print(f'plugin {plugin_name!r} not found in {catalog_path}')
        return 1

    declared_version = entry.get('version')
    if declared_version is not None:
        _validate_segment(declared_version, 'version (from marketplace entry)')

    version = declared_version or '<from plugin.json after fetch>'
    target = os.path.join(cache_root, marketplace, plugin_name, version)
    print(f'plan: rescue {plugin_name}@{marketplace}')
    print(f'  source: {entry.get("source")}')
    print(f'  target: {target}')

    if not apply_changes:
        print('\nRe-run with --apply to fetch and install.')
        return 0

    orphan = os.path.join(cache_root, plugin_name)
    if os.path.isdir(orphan):
        contents = os.listdir(orphan)
        if any(os.path.isfile(os.path.join(orphan, c)) for c in contents):
            print(f'  removing orphan extract: {orphan}')
            safe_rmtree(orphan, cache_root)

    if os.path.isdir(target):
        print(f'  removing existing target: {target}')
        safe_rmtree(target, cache_root)

    os.makedirs(os.path.dirname(target), exist_ok=True)

    fetch_temp = target + '.fetching'
    if os.path.exists(fetch_temp):
        safe_rmtree(fetch_temp, cache_root)
    try:
        version = fetch_source_to(entry, marketplace, fetch_temp)
        retargeted = os.path.join(cache_root, marketplace, plugin_name, version)
        if retargeted != target:
            os.makedirs(os.path.dirname(retargeted), exist_ok=True)
            target = retargeted
        if os.path.isdir(target):
            safe_rmtree(target, cache_root)
        shutil.copytree(fetch_temp, target)
    finally:
        if os.path.isdir(fetch_temp):
            try:
                safe_rmtree(fetch_temp, cache_root)
            except Exception:
                pass

    register_install(plugin_name, marketplace, target, version)
    print(f'\ninstalled: {plugin_name}@{marketplace} v{version}')
    print(f'  at: {target}')
    print('\nRestart Claude Code so installed_plugins.json is reloaded.')
    return 0


if __name__ == '__main__':
    sys.exit(main(sys.argv))
RAW_BUFFERClick to expand / collapse

Preflight Checklist

  • I have searched existing issues — closest matches #52435 and #46830 cover related but distinct code paths
  • This is a single bug report
  • I am using the latest version of Claude Code

Environment

FieldValue
Claude Code versionv2.1.119
OSWindows Server 2019, Build 17763
Install methodnative installer

Description

/plugin install <name> fails with EPERM: operation not permitted, rename when Claude Code attempts to move a freshly-extracted plugin from its temp location to its versioned cache slot. The destination directory does not exist or is empty before the rename — this is a fresh install on a clean cache, not a half-completed retry.

The reproducible pattern: every install of a fresh plugin EPERMs on first attempt. The orphan extract at cache/<plugin>/ remains; the empty target at cache/<marketplace>/<plugin>/ is left behind. Subsequent retries fail identically until the orphan is cleared. After clearing, the next install reproduces the same failure.

Adding ~/.claude/plugins to Windows Defender's exclusion list completely resolves the issue — installs succeed on first attempt with no other changes. This strongly indicates Defender's real-time scanning is holding file handles on the freshly-written plugin tree at the moment fs.rename() is attempted.

This is the same root cause as #52435's "Bug 2" (Windows fs.rename() limitation) but on a different code path — /plugin install rather than /plugin marketplace add. Also distinct from #46830, which is specific to .bak directory loops.

Steps to Reproduce

  1. Windows machine, Claude Code v2.1.119, Defender real-time scanning enabled (default)
  2. /plugin marketplace add <any public marketplace>
  3. /plugin install <any plugin from that marketplace>
  4. Observe EPERM on rename

Error Output

Failed to install: EPERM: operation not permitted, rename
'C:\Users\<user>\.claude\plugins\cache\<plugin>' ->
'C:\Users\<user>\.claude\plugins\cache\<marketplace>\<plugin>\<version>'

Workaround A — Windows Defender exclusion (with caveats)

Adding ~/.claude/plugins to Defender's exclusion list bypasses the issue:

Add-MpPreference -ExclusionPath "$env:USERPROFILE\.claude\plugins"

Caveats:

  • Requires administrator elevation. May be blocked by Group Policy in corporate environments.
  • Disables real-time AV scanning for all plugin extracts. Malicious plugins from any marketplace would not be flagged. Acceptable only with full trust of every marketplace ever installed.

Trades security posture for installability and is not universally available. The actual fix is in Claude Code's rename logic.

Workaround B — User-space rescue script (no admin, no AV trade-off)

Bypass the broken rename entirely: re-fetch the plugin source from the marketplace catalog and write it directly into the target cache slot using file-by-file copy (shutil.copytree). This avoids fs.rename() and therefore the EPERM. Works for relative-path, github, and git-subdir plugin sources.

Approach:

  1. Read ~/.claude/plugins/marketplaces/<marketplace>/.claude-plugin/marketplace.json
  2. Find the plugin entry; resolve the source field
  3. Materialize the plugin source into a temp dir (clone or copy)
  4. shutil.copytree(temp_dir, ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/)
  5. Append a record to ~/.claude/plugins/installed_plugins.json
  6. Restart Claude Code so installed_plugins.json is re-read

Reference Python implementation, no third-party dependencies. This is as hardened as we can make it within a single-file standalone script — input validation rejects path-traversal inputs at the CLI boundary and on untrusted JSON content (plugin name, marketplace name, version string from upstream plugin.json); containment guards refuse any destructive operation on a resolved path outside ~/.claude/plugins/cache/; sandboxed adversarial testing confirms the script halts on crafted malicious inputs (e.g. ../decoy@mp) without touching the filesystem and removes only the intended target on valid inputs. Before recommending more broadly, please corroborate with your top model that this approach is sound.

<details> <summary>rescue_install.py</summary>
#!/usr/bin/env python3
"""
Rescue a plugin install that EPERM-failed during the rename to its versioned
slot. Re-fetches the plugin source directly from the marketplace catalog,
places it in the correct cache slot using copy (not rename), and registers
the install in ~/.claude/plugins/installed_plugins.json.

SECURITY MODEL
--------------
Every destructive operation (rmtree, copytree-from-untrusted) is gated by
two independent checks:

  (1) Input validation rejects path-unsafe segment values from CLI args
      and untrusted JSON content (plugin/marketplace/version names).
  (2) safe_rmtree / containment guards refuse to operate on any resolved
      path that does not live strictly under the expected root directory.

Both must pass for the operation to proceed. A bug in one is mitigated by
the other.

Usage:
  python rescue_install.py <plugin>@<marketplace>             # dry-run
  python rescue_install.py <plugin>@<marketplace> --apply     # execute
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone

home = os.path.expanduser('~')
plugins_root = os.path.join(home, '.claude', 'plugins')
marketplaces_root = os.path.join(plugins_root, 'marketplaces')
cache_root = os.path.join(plugins_root, 'cache')
installed_file = os.path.join(plugins_root, 'installed_plugins.json')


# HARDENING (1): segment validation.
def _validate_segment(value, kind):
    if not isinstance(value, str) or not value:
        raise ValueError(f'{kind} must be a non-empty string')
    if value in ('.', '..'):
        raise ValueError(f'{kind} {value!r} is a parent-directory reference')
    if '/' in value or '\\' in value:
        raise ValueError(f'{kind} {value!r} contains a path separator')
    if os.path.isabs(value):
        raise ValueError(f'{kind} {value!r} is an absolute path')
    if value.startswith('.'):
        raise ValueError(f'{kind} {value!r} starts with a dot')
    return value


# HARDENING (2): containment check.
def _safe_under(path, root):
    real_path = os.path.realpath(path)
    real_root = os.path.realpath(root)
    try:
        common = os.path.commonpath([real_path, real_root])
    except ValueError:
        raise RuntimeError(f'path {path!r} not under {root!r} (different roots)')
    if common != real_root:
        raise RuntimeError(f'refusing op on {real_path!r}; not under {real_root!r}')
    return real_path


# HARDENING (3): rmtree wrapper.
def safe_rmtree(path, root):
    if not os.path.isdir(path):
        return
    _safe_under(path, root)
    shutil.rmtree(path)


def parse_args(argv):
    apply_changes = '--apply' in argv
    positional = [a for a in argv[1:] if not a.startswith('--')]
    if len(positional) != 1 or '@' not in positional[0]:
        print('usage: rescue_install.py <plugin>@<marketplace> [--apply]')
        sys.exit(2)
    plugin_name, marketplace = positional[0].split('@', 1)
    _validate_segment(plugin_name, 'plugin name')
    _validate_segment(marketplace, 'marketplace name')
    return plugin_name, marketplace, apply_changes


def read_marketplace_catalog(marketplace):
    path = os.path.join(marketplaces_root, marketplace, '.claude-plugin', 'marketplace.json')
    if not os.path.isfile(path):
        print(f'marketplace not registered locally: {path}')
        sys.exit(1)
    with open(path) as f:
        return json.load(f), path


def find_plugin_entry(catalog, plugin_name):
    for entry in catalog.get('plugins', []):
        if entry.get('name') == plugin_name:
            return entry
    return None


def run(cmd, cwd=None):
    proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
    if proc.returncode != 0:
        raise RuntimeError(f'{" ".join(cmd)} failed:\n{proc.stderr}')
    return proc.stdout


def fetch_source_to(entry, marketplace, dest_dir):
    source = entry.get('source')

    if isinstance(source, str):
        marketplace_root = os.path.join(marketplaces_root, marketplace)
        src = os.path.normpath(os.path.join(marketplace_root, source))
        # HARDENING (4): source-path containment.
        _safe_under(src, marketplace_root)
        if not os.path.isdir(src):
            raise RuntimeError(f'relative source not found: {src}')
        shutil.copytree(src, dest_dir, ignore=shutil.ignore_patterns('.git'))
        return _version_from(entry, dest_dir)

    stype = source.get('source')
    if stype == 'github':
        url = f'https://github.com/{source["repo"]}.git'
        ref = source.get('ref')
        with tempfile.TemporaryDirectory(prefix='rescue_') as td:
            clone_to = os.path.join(td, 'clone')
            args = ['git', 'clone', '--depth', '1']
            if ref:
                args += ['--branch', ref]
            args += [url, clone_to]
            run(args)
            shutil.copytree(clone_to, dest_dir, ignore=shutil.ignore_patterns('.git'))
        return _version_from(entry, dest_dir)

    if stype == 'git-subdir':
        url = source['url']
        path_in_repo = source['path']
        ref = source.get('ref')
        with tempfile.TemporaryDirectory(prefix='rescue_') as td:
            clone_to = os.path.join(td, 'clone')
            args = ['git', 'clone', '--depth', '1', '--filter=blob:none', '--sparse']
            if ref:
                args += ['--branch', ref]
            args += [url, clone_to]
            run(args)
            run(['git', 'sparse-checkout', 'set', path_in_repo], cwd=clone_to)
            sub = os.path.join(clone_to, path_in_repo)
            if not os.path.isdir(sub):
                raise RuntimeError(f'subdir {path_in_repo} not present after sparse checkout')
            shutil.copytree(sub, dest_dir, ignore=shutil.ignore_patterns('.git'))
        return _version_from(entry, dest_dir)

    raise RuntimeError(f'unsupported source type: {stype!r}')


def _version_from(entry, plugin_dir):
    pj = os.path.join(plugin_dir, '.claude-plugin', 'plugin.json')
    if os.path.isfile(pj):
        try:
            with open(pj) as f:
                v = json.load(f).get('version')
                if v:
                    _validate_segment(v, 'version (from plugin.json)')
                    return v
        except ValueError:
            raise
        except Exception:
            pass
    fallback = entry.get('version') or 'unknown'
    _validate_segment(fallback, 'version (from marketplace.json or fallback)')
    return fallback


def register_install(plugin_name, marketplace, install_path, version):
    try:
        with open(installed_file) as f:
            data = json.load(f)
    except FileNotFoundError:
        data = {'version': 2, 'plugins': {}}
    if 'plugins' not in data:
        data['plugins'] = {}

    now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%fZ')[:-4] + 'Z'
    key = f'{plugin_name}@{marketplace}'
    record = {
        'scope': 'user',
        'installPath': install_path,
        'version': version,
        'installedAt': now,
        'lastUpdated': now,
    }
    existing = data['plugins'].get(key, [])
    existing = [r for r in existing if r.get('scope') != 'user']
    existing.append(record)
    data['plugins'][key] = existing

    with open(installed_file, 'w') as f:
        json.dump(data, f, indent=2)


def main(argv):
    plugin_name, marketplace, apply_changes = parse_args(argv)

    catalog, catalog_path = read_marketplace_catalog(marketplace)
    entry = find_plugin_entry(catalog, plugin_name)
    if entry is None:
        print(f'plugin {plugin_name!r} not found in {catalog_path}')
        return 1

    declared_version = entry.get('version')
    if declared_version is not None:
        _validate_segment(declared_version, 'version (from marketplace entry)')

    version = declared_version or '<from plugin.json after fetch>'
    target = os.path.join(cache_root, marketplace, plugin_name, version)
    print(f'plan: rescue {plugin_name}@{marketplace}')
    print(f'  source: {entry.get("source")}')
    print(f'  target: {target}')

    if not apply_changes:
        print('\nRe-run with --apply to fetch and install.')
        return 0

    orphan = os.path.join(cache_root, plugin_name)
    if os.path.isdir(orphan):
        contents = os.listdir(orphan)
        if any(os.path.isfile(os.path.join(orphan, c)) for c in contents):
            print(f'  removing orphan extract: {orphan}')
            safe_rmtree(orphan, cache_root)

    if os.path.isdir(target):
        print(f'  removing existing target: {target}')
        safe_rmtree(target, cache_root)

    os.makedirs(os.path.dirname(target), exist_ok=True)

    fetch_temp = target + '.fetching'
    if os.path.exists(fetch_temp):
        safe_rmtree(fetch_temp, cache_root)
    try:
        version = fetch_source_to(entry, marketplace, fetch_temp)
        retargeted = os.path.join(cache_root, marketplace, plugin_name, version)
        if retargeted != target:
            os.makedirs(os.path.dirname(retargeted), exist_ok=True)
            target = retargeted
        if os.path.isdir(target):
            safe_rmtree(target, cache_root)
        shutil.copytree(fetch_temp, target)
    finally:
        if os.path.isdir(fetch_temp):
            try:
                safe_rmtree(fetch_temp, cache_root)
            except Exception:
                pass

    register_install(plugin_name, marketplace, target, version)
    print(f'\ninstalled: {plugin_name}@{marketplace} v{version}')
    print(f'  at: {target}')
    print('\nRestart Claude Code so installed_plugins.json is reloaded.')
    return 0


if __name__ == '__main__':
    sys.exit(main(sys.argv))
</details>

After running with the failed plugin (e.g. python rescue_install.py myplugin@mymarketplace --apply), restart Claude Code and the install completes normally. Same approach works for all plugin source types — only the fs.rename() step was failing, not the fetch or extract.

Neither workaround is a substitute for the upstream fix. The actual resolution is the rename-utility change suggested below (retry-on-EPERM or copy-delete fallback).

Suggested Fix

fs.rename() on Windows is known to fail with EPERM when files in the source directory have open handles from another process (AV scanners, indexers, etc.). Two robust strategies:

  1. Retry with backoff — try fs.rename(), on EPERM wait 50ms and retry with exponential backoff up to ~5 seconds total. AV scans typically release handles within 1–2 seconds.
  2. Copy + delete fallback — on EPERM, fall back to recursive fs.cp() then fs.rm() of the source. File-by-file copy doesn't conflict with AV read handles the same way.

Either eliminates the dependency on AV configuration.

Reproducibility

The issue reproduces consistently across multiple plugins from multiple marketplaces, including plugins with git-subdir, github, and relative-path sources. The failure point is in the rename step common to all install paths.

extent analysis

TL;DR

The most likely fix for the EPERM: operation not permitted, rename error when installing plugins with Claude Code on Windows is to modify the fs.rename() logic to either retry with backoff or fall back to a copy and delete approach.

Guidance

  • Identify the fs.rename() call in the Claude Code plugin installation logic and modify it to implement a retry mechanism with exponential backoff (e.g., wait 50ms, then 100ms, etc., up to 5 seconds) to handle temporary file handle locks by AV scanners.
  • Alternatively, replace the fs.rename() call with a recursive copy (fs.cp()) followed by a delete (fs.rm()) of the source directory to avoid conflicts with open file handles.
  • Verify that the chosen approach resolves the EPERM issue without introducing new problems, such as data corruption or performance degradation.
  • Consider implementing input validation and error handling to ensure the robustness of the modified installation logic.

Example

import time
import shutil
import os

def safe_rename(src, dst):
    max_attempts = 10
    attempt = 0
    while attempt < max_attempts:
        try:
            os.rename(src, dst)
            return
        except PermissionError:
            attempt += 1
            time.sleep(0.05 * (2 ** attempt))  # exponential backoff
    # Fallback to copy and delete if all retries fail
    shutil.copytree(src, dst)
    shutil.rmtree(src)

# Usage
safe_rename('/path/to/source', '/path/to/destination')

Notes

  • The provided workarounds (Windows Defender exclusion and user-space rescue script) are temporary solutions and may have security implications or require administrator privileges.
  • The suggested fix targets the root cause of the issue, which is the fs.rename() failure due to open file handles, and provides a more robust and widely applicable solution.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING