// 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 limits — these still count as own: a `git archive` // export or unpacked tarball under cwd (no marker at all), and other install // locations this rule does not name (a Python venv's site-packages, a // composer/bundler `vendor/` dir, skills copied into a git-tracked ~/.claude). 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; } } /** * 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} */ export function isOwnWorkingTree(targetPath) { let resolvedTarget; let resolvedCwd; let resolvedTmp; try { resolvedTarget = realpathSync(resolve(targetPath)); resolvedCwd = realpathSync(process.cwd()); resolvedTmp = realpathSync(tmpdir()); } catch { return false; } 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); if (!underCwd) return false; if (relative(resolvedCwd, resolvedTarget).split(sep).includes('node_modules')) return false; return gitRoot(resolvedTarget) === gitRoot(resolvedCwd); }