fix(scope): node_modules and the Claude Code plugin dir are foreign

A target with no .git of its own under cwd shares cwd's git root, so the
v8.1.1 rule alone called it own: an installed package under a repo and a
plugin-cache copy still had their .llm-security-ignore / policy.json read.

Chosen (PM rule, order 20260922T192716Z): a target is additionally foreign
when the path from cwd to it has a node_modules segment, or when it lies
under $CLAUDE_CONFIG_DIR/plugins (default ~/.claude/plugins). Because those
are the two concrete places foreign code lands under a user's working
directory, the failure direction is safe (foreign means more findings), and
a general "no .git of its own" rule would shut out ordinary subdirectories
of the caller's own repo. git archive exports stay indistinguishable from
own subdirectories; documented as a known limit in the module header.

Red first: 6 unit + 6 orchestrator assertions failed before the fix;
known-positives (plain subdir, workspace package dir, rest of config dir)
passed before and after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-22 21:32:40 +02:00
commit a61c1c9648
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 323 additions and 3 deletions

View file

@ -19,10 +19,23 @@
// therefore foreign. No git subprocess: the walk only stats `.git`. The
// failure direction is safe — foreign means the target's config is ignored,
// so more findings, never fewer.
//
// v8.1.2 closed the gap for foreign code with NO `.git` of its own under cwd
// (it shares cwd's git root, so the v8.1.1 rule alone called it own). Two
// concrete places put such code under a user's working directory, and both
// are now foreign: (1) any `node_modules` segment on the path from cwd to the
// target (an installed package; only the path BELOW cwd counts, so a package
// the user has cd'd into is own, like a clone they cd'd into), and (2) any
// target under Claude Code's plugin dir — `$CLAUDE_CONFIG_DIR/plugins`, default
// `~/.claude/plugins` (cache/ and marketplaces/). A general "no `.git` of its
// own" rule was not taken: it would shut out ordinary subdirectories of the
// caller's own repo. Known limit: a `git archive` export (or an unpacked
// tarball) under cwd has neither marker and cannot be told apart from an own
// subdirectory — it still counts as own.
import { resolve, sep, join, dirname } from 'node:path';
import { resolve, sep, join, dirname, relative } from 'node:path';
import { realpathSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { tmpdir, homedir } from 'node:os';
/**
* Nearest ancestor of `start` (inclusive) that holds a `.git` entry, or null.
@ -39,6 +52,21 @@ function gitRoot(start) {
}
}
/**
* Claude Code's plugin dir: `$CLAUDE_CONFIG_DIR/plugins` (a relative value is
* resolved against cwd), else `~/.claude/plugins`. Realpath'd when it exists.
* @returns {string}
*/
function pluginDir() {
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
const dir = resolve(configDir, 'plugins');
try {
return realpathSync(dir);
} catch {
return dir;
}
}
/**
* @param {string} targetPath
* @returns {boolean}
@ -57,6 +85,12 @@ export function isOwnWorkingTree(targetPath) {
if (resolvedTarget === resolvedTmp || resolvedTarget.startsWith(resolvedTmp + sep)) {
return false;
}
const plugins = pluginDir();
if (resolvedTarget === plugins || resolvedTarget.startsWith(plugins + sep)) {
return false;
}
const underCwd = resolvedTarget === resolvedCwd || resolvedTarget.startsWith(resolvedCwd + sep);
return underCwd && gitRoot(resolvedTarget) === gitRoot(resolvedCwd);
if (!underCwd) return false;
if (relative(resolvedCwd, resolvedTarget).split(sep).includes('node_modules')) return false;
return gitRoot(resolvedTarget) === gitRoot(resolvedCwd);
}