fix(scope): a clone under cwd is not the caller's own working tree

isOwnWorkingTree() treated any target at or below cwd as own, so a foreign
clone under cwd (cwd = $HOME, a vendor clone in a project) still had its
.llm-security-ignore, policy.json and custom SIG rules read. The target must
now also share cwd's git root: nearest ancestor with a `.git` entry (dir for
a clone, file for a submodule/worktree), or none for both. No git spawn.
tmpdir stays foreign. Chosen per the PM order: it is exactly the line
between "my repo" and "something I fetched", and the failure direction is
safe (foreign => config ignored => more findings, never fewer).

Red first: tests/lib/own-working-tree.test.mjs 4 fail / 5 pass (the 5 are
known-positives), tests/scanners/nested-clone-scope.test.mjs 5 fail / 4 pass
on the old rule. Green after; reverting the git-root comparison turns 9 red.
Suite 2306 / 2300 pass / 0 fail / 6 skip; hooks 370/0 (implicit root
untouched); golden 109/7/4, 61/61.

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

View file

@ -4,18 +4,41 @@
// 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), and NEVER when the target resolves under the OS temp
// 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.
import { resolve, sep } from 'node:path';
import { realpathSync } from 'node:fs';
import { resolve, sep, join, dirname } from 'node:path';
import { realpathSync, existsSync } from 'node:fs';
import { tmpdir } 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;
}
}
/**
* @param {string} targetPath
* @returns {boolean}
@ -34,5 +57,6 @@ export function isOwnWorkingTree(targetPath) {
if (resolvedTarget === resolvedTmp || resolvedTarget.startsWith(resolvedTmp + sep)) {
return false;
}
return resolvedTarget === resolvedCwd || resolvedTarget.startsWith(resolvedCwd + sep);
const underCwd = resolvedTarget === resolvedCwd || resolvedTarget.startsWith(resolvedCwd + sep);
return underCwd && gitRoot(resolvedTarget) === gitRoot(resolvedCwd);
}