llm-security/scanners/lib/own-working-tree.mjs
Kjell Tore Guttormsen 0bc4c8a6be
chore(release): v8.1.2 — node_modules and plugin dir foreign, posture credit honest
Version sync: package.json, .claude-plugin/plugin.json, README badge +
Recent versions row, CLAUDE.md header + v8.1.2 highlights, CHANGELOG
[8.1.2]. The own-working-tree header and CHANGELOG no longer claim that
node_modules and the plugin dir are the only install locations foreign
code lands in (independent review: Python venv, vendor/ still own) —
listed as Known open instead.

Gates on the staged tree: suite 2328 / 2322 pass / 0 fail / 6 skipped,
hooks 370/0, golden 109/7/4 61/61, av-surface 6/6, claude plugin
validate pass (1 known CLAUDE.md warning).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 21:40:05 +02:00

96 lines
4 KiB
JavaScript

// 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);
}