// own-working-tree.mjs — Is a scan target the caller's own working tree? // Zero external dependencies. // // Configuration that lives INSIDE a scanned target (.llm-security-ignore, // .llm-security/policy.json and the custom SIG ruleset it can point at) is // honored only when the target is the caller's own working directory (or a // subdirectory of it in the same git working tree), and NEVER when the target resolves under the OS temp // directory (where git-clone.mjs materializes clones) — the second check is // defense-in-depth for the case a caller's own cwd sits under tmpdir. // Otherwise a foreign/cloned target could configure the scan of itself. // // S3b (v8.1.0, 2026-09-22) introduced this check in scan-orchestrator.mjs for // the ignore file; S3c moved it here so policy-loader.mjs shares the one rule. // // v8.1.1 narrowed "at or below cwd": the target must also have the SAME git // root as cwd — the nearest ancestor holding a `.git` (a directory for a // clone, a file for a submodule or worktree), or no git root for either. A // clone under cwd (cwd = $HOME, or a vendor clone inside a project) is // 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) for two // install locations, which 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 in v8.1.2: other install locations still // counted as own. // // v8.1.3 (PM decisions) widened both checks: // (1) a `site-packages` (Python venv) or `vendor` (composer/bundler/Go) // segment below cwd is foreign like `node_modules`, and a Claude Code config // dir's `skills/` is foreign like its `plugins/` (third-party skills copied // into a git-tracked ~/.claude share its git root); (2) the config dirs are // BOTH `~/.claude` and `$CLAUDE_CONFIG_DIR`, whose leading `~` is expanded // (Anthropic's docs do not say whether Claude Code expands it — not verified, // so both readings count as foreign); (3) paths are realpath'd with // `realpathSync.native`, which canonicalizes case on a case-insensitive // volume: `NODE_MODULES/x` is the `node_modules` it names, and a // case-mismatched path to an own dir is own. Still own (known limits): a // `git archive` export or unpacked tarball under cwd (no marker at all), and // cwd inside `node_modules` with a sibling package as target. import { resolve, sep, join, dirname, relative } from 'node:path'; import { realpathSync, existsSync } from 'node:fs'; import { tmpdir, homedir } from 'node:os'; /** * Nearest ancestor of `start` (inclusive) that holds a `.git` entry, or null. * @param {string} start - a realpath * @returns {string|null} */ function gitRoot(start) { let dir = start; for (;;) { if (existsSync(join(dir, '.git'))) return dir; const parent = dirname(dir); if (parent === dir) return null; dir = parent; } } // Path segments below cwd that mark installed third-party code. const INSTALL_SEGMENTS = ['node_modules', 'site-packages', 'vendor']; // Subdirs of a Claude Code config dir that hold third-party code. const CONFIG_INSTALL_DIRS = ['plugins', 'skills']; /** Realpath with case canonicalized; the input itself when it does not exist. */ function realOrSelf(p) { try { return realpathSync.native(p); } catch { return p; } } /** * Claude Code's install dirs: `plugins/` and `skills/` under `~/.claude` and, * when set, under `$CLAUDE_CONFIG_DIR` (a leading `~` is expanded, a relative * value is resolved against cwd). * @returns {string[]} */ function configInstallDirs() { const configDirs = [join(homedir(), '.claude')]; const env = process.env.CLAUDE_CONFIG_DIR; if (env) { const expanded = env === '~' || env.startsWith('~/') ? join(homedir(), env.slice(1)) : env; configDirs.push(resolve(expanded)); } return configDirs.flatMap(dir => CONFIG_INSTALL_DIRS.map(sub => realOrSelf(join(dir, sub)))); } /** * @param {string} targetPath * @returns {boolean} */ export function isOwnWorkingTree(targetPath) { let resolvedTarget; let resolvedCwd; let resolvedTmp; try { resolvedTarget = realpathSync.native(resolve(targetPath)); resolvedCwd = realpathSync.native(process.cwd()); resolvedTmp = realpathSync.native(tmpdir()); } catch { return false; } if (resolvedTarget === resolvedTmp || resolvedTarget.startsWith(resolvedTmp + sep)) { return false; } for (const dir of configInstallDirs()) { if (resolvedTarget === dir || resolvedTarget.startsWith(dir + sep)) return false; } const underCwd = resolvedTarget === resolvedCwd || resolvedTarget.startsWith(resolvedCwd + sep); if (!underCwd) return false; const below = relative(resolvedCwd, resolvedTarget).split(sep); if (below.some(segment => INSTALL_SEGMENTS.includes(segment))) return false; return gitRoot(resolvedTarget) === gitRoot(resolvedCwd); }