Plugin marketplace `git checkout` uses process CWD as work-tree — leaks plugin tree into the caller's directory
Summary
When claude-code installs/resolves a plugin whose marketplace source is an external git repo, it runs git checkout <sha> without pinning --work-tree (or -C), so the checkout's work tree is the git process's current working directory. In code paths where that cwd is not the temporary clone directory, the entire plugin file tree is materialized into the caller's working directory via O_WRONLY|O_CREAT|O_EXCL.
For an interactive user this is usually invisible (the install path happens to chdir into the temp clone first). But when claude-code is launched non-interactively from another working directory (e.g. an agent/automation harness that spawns claude inside a project repo), a plugin-resolution that takes the un-pinned path dumps files like package.json, .mcp.json, README.md, .claude-plugin/plugin.json, skills/**, etc. into that project directory. Because the writes use O_CREAT|O_EXCL, only missing filenames are created (existing files survive), which is why it shows up as a scatter of foreign/empty-looking config stubs and can break the next tool that reads e.g. package.json in that dir.
- Version: 2.1.161 (native install, Linux)
- Mechanism reproduced live under
strace -f(see below)
Root cause / evidence
Plugin resolution does a checkout-less shallow clone of the external source into a temp dir, then a separate git checkout <sha>:
execve("/usr/bin/git", ["git", "-c","core.sshCommand=...", "clone", "--depth","1",
"--recurse-submodules","--shallow-submodules","--no-checkout","--",
"https://github.com/AikidoSec/aikido-claude-plugin.git",
"<CLAUDE_CONFIG_DIR>/plugins/cache/temp_git_<ts>_<rand>"]) = 0
execve("/usr/bin/git", ["git", "checkout", "<sha>"]) = 0 # <-- no --work-tree, no -C
The git checkout then writes relative to the process cwd (AT_FDCWD):
openat(AT_FDCWD, ".claude-plugin/plugin.json", O_WRONLY|O_CREAT|O_EXCL, 0666) = 4
openat(AT_FDCWD, ".claude-plugin/marketplace.json", O_WRONLY|O_CREAT|O_EXCL, 0666) = 4
openat(AT_FDCWD, ".mcp.json", O_WRONLY|O_CREAT|O_EXCL, 0666) = 4
openat(AT_FDCWD, "README.md", O_WRONLY|O_CREAT|O_EXCL, 0666) = 4
openat(AT_FDCWD, "skills/scan/SKILL.md", O_WRONLY|O_CREAT|O_EXCL, 0666) = 4
...
Because the work tree is determined by cwd, the destination is correct only as long as the caller chdir'd into the temp clone first. The behavior is fragile by construction — any resolution path (or future refactor, or a concurrent operation that changes cwd) that runs the checkout without that chdir will materialize the plugin tree wherever the claude process happens to be running. The leftover temp_git_* clone dirs contain a bare .git with no work tree, confirming the checkout's output went elsewhere.
Reproduction (mechanism)
CFG="$(mktemp -d)"; WORK="$(mktemp -d)"; ( cd "$WORK" && git init -q )
# add the official marketplace into an isolated config, then install a plugin
# whose marketplace source is an external git repo:
CLAUDE_CONFIG_DIR="$CFG" claude plugin marketplace add anthropics/claude-plugins-official
cd "$WORK"
CLAUDE_CONFIG_DIR="$CFG" strace -f -e trace=openat,chdir,execve -o /tmp/s.log \
claude plugin install aikido@claude-plugins-official
grep -E 'execve\("/usr/bin/git".*(clone|checkout)|openat\(AT_FDCWD, "[^/]' /tmp/s.log
This shows the clone --no-checkout + git checkout <sha> pair and that the checkout writes via openat(AT_FDCWD, "<relative path>", O_WRONLY|O_CREAT|O_EXCL) — i.e. the work tree is the process cwd. (In the install path claude chdirs into the temp clone, so files land correctly; the bug surfaces on the resolution paths that don't, which is what gets observed as files appearing in a caller's project directory.)
Suggested fix
Pin the work tree explicitly on every plugin/marketplace git checkout/read-tree rather than relying on the inherited cwd — e.g. git -C <tmpCloneDir> checkout <sha> or git --git-dir=<tmp>/.git --work-tree=<tmp> checkout <sha> (and/or spawn with cwd: <tmpCloneDir>). That makes the destination independent of wherever the claude process was launched.
Workaround (for harness authors)
Launch the spawned claude with an isolated, plugin-free CLAUDE_CONFIG_DIR (a fresh dir carrying only a symlink to .credentials.json, no settings.json/plugins/). With no enabled plugins / known marketplaces, no external source is ever resolved, so nothing can be checked out into the worker's cwd. (DISABLE_AUTOUPDATER=1 does not prevent this — it gates the binary self-updater, not the plugin-marketplace machinery.)
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗