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

@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Fixed
- **A clone under the working directory is no longer "own working tree".**
v8.1.0 honoured `.llm-security-ignore` and `.llm-security/policy.json`
(including `sig.custom_rules_path`) for any target at or below the working
directory, so a foreign clone there — the working directory at `$HOME`, or a
cloned vendor folder inside a project — could still suppress or tune its own
scan. The target must now also have the same git root as the working
directory: the nearest folder above it holding a `.git` (a directory for a
clone, a file for a submodule or worktree), or no git root for either
(`scanners/lib/own-working-tree.mjs`). Temp-directory targets stay foreign.
**Behaviour change:** scanning a nested clone, submodule or worktree from
the parent repository no longer applies that checkout's ignore file or
policy; it now gets the defaults and one stderr line per ignored file.
Scanning the repository itself or any plain folder inside it is unchanged,
and so are hooks, which read the policy from the project root.
## [8.1.0] - 2026-09-22
Antivirus surface. A Windows user should be able to clone the repository and

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

View file

@ -0,0 +1,102 @@
// own-working-tree.test.mjs — isOwnWorkingTree() rule (v8.1.1).
//
// v8.1.0 treated any target at or below the process cwd as the caller's own
// working tree. That is too wide: a foreign clone that sits UNDER cwd (cwd =
// $HOME, or a project with a cloned vendor dir) still had its .llm-security
// config read. v8.1.1 narrows the rule: the target must be at or below cwd
// AND have the same git root as cwd — the nearest ancestor holding a `.git`
// (directory for a clone, file for a submodule/worktree), or none for both.
// A nested clone therefore counts as foreign. The failure direction is safe:
// foreign means the target's config is ignored, so more findings, never fewer.
//
// Fixtures live under $HOME (outside os.tmpdir(), which is always foreign,
// and outside this repo's git tree); $HOME itself must not be a git root.
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { isOwnWorkingTree } from '../../scanners/lib/own-working-tree.mjs';
import { mkOwnTreeDir } from '../helpers/own-tree.mjs';
function gitInit(dir) {
mkdirSync(dir, { recursive: true });
const r = spawnSync('git', ['init', '-q', dir], { encoding: 'utf8' });
assert.equal(r.status, 0, `git init failed: ${r.stderr}`);
}
function ownFrom(cwd, target) {
const prev = process.cwd();
process.chdir(cwd);
try {
return isOwnWorkingTree(target);
} finally {
process.chdir(prev);
}
}
describe('isOwnWorkingTree(): same git root as cwd (v8.1.1)', () => {
let root;
let repo;
let homeish;
before(() => {
assert.ok(!existsSync(join(homedir(), '.git')), 'precondition: $HOME is not a git root');
root = mkOwnTreeDir('owt-unit-');
// A repo the user works in, with a plain subdir, a nested clone and a
// submodule-style checkout (`.git` is a file).
repo = join(root, 'repo');
gitInit(repo);
mkdirSync(join(repo, 'sub', 'deeper'), { recursive: true });
gitInit(join(repo, 'vendor', 'clone'));
mkdirSync(join(repo, 'vendor', 'clone', 'inner'), { recursive: true });
mkdirSync(join(repo, 'submod'), { recursive: true });
writeFileSync(join(repo, 'submod', '.git'), 'gitdir: ../.git/modules/submod\n');
// A $HOME-like dir: no git root, holding one clone and one plain dir.
homeish = join(root, 'homeish');
mkdirSync(join(homeish, 'plain'), { recursive: true });
gitInit(join(homeish, 'clone'));
});
after(() => { rmSync(root, { recursive: true, force: true }); });
it('cwd itself is own', () => {
assert.equal(ownFrom(repo, repo), true);
assert.equal(ownFrom(repo, '.'), true);
});
it('(b) a subdir of the same repo is own (known-positive)', () => {
assert.equal(ownFrom(repo, join(repo, 'sub')), true);
assert.equal(ownFrom(join(repo, 'sub'), join(repo, 'sub', 'deeper')), true);
});
it('(a) a nested clone under the repo is foreign', () => {
assert.equal(ownFrom(repo, join(repo, 'vendor', 'clone')), false);
});
it('(a) a subdir inside a nested clone is foreign', () => {
assert.equal(ownFrom(repo, join(repo, 'vendor', 'clone', 'inner')), false);
});
it('a submodule/worktree checkout (`.git` file) is foreign', () => {
assert.equal(ownFrom(repo, join(repo, 'submod')), false);
});
it('(c) a clone under a cwd with no git root is foreign', () => {
assert.equal(ownFrom(homeish, join(homeish, 'clone')), false);
});
it('a plain subdir of a cwd with no git root is own', () => {
assert.equal(ownFrom(homeish, join(homeish, 'plain')), true);
});
it('a clone the user has cd\'d into is own (it is cwd)', () => {
assert.equal(ownFrom(join(homeish, 'clone'), '.'), true);
});
it('a target outside cwd is foreign', () => {
assert.equal(ownFrom(join(repo, 'sub'), repo), false);
});
});

View file

@ -0,0 +1,199 @@
// nested-clone-scope.test.mjs — a foreign clone that sits UNDER the caller's
// cwd must not configure its own scan (v8.1.1).
//
// v8.1.0 (S3b/S3c) honored .llm-security-ignore and .llm-security/policy.json
// for any target at or below cwd. A clone under cwd (cwd = $HOME, or a vendor
// clone inside a project) could therefore still ship `**` in its ignore file
// and a policy that raises the entropy thresholds and loads its own SIG rules.
// v8.1.1: own tree = at or below cwd AND the same git root as cwd.
//
// Fixture: the known HIGH entropy blob + custom SIG rule from
// policy-scope.test.mjs, plus a `**` ignore file (would suppress everything).
// (a) cwd = a repo, target = a nested clone (own `.git`) WITH the config:
// findings identical to the same clone WITHOUT the config files.
// (b) cwd = a repo, target = a plain subdir of that SAME repo with the
// policy: still honored (known-positive — keeps (a) from being vacuous).
// (c) cwd = a $HOME-like dir with no git root, target = a clone under it:
// foreign, the known finding survives.
// All under $HOME: outside os.tmpdir() (always foreign) and this repo's tree.
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import crypto from 'node:crypto';
import { mkOwnTreeDir } from '../helpers/own-tree.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ORCHESTRATOR = resolve(__dirname, '../../scanners/scan-orchestrator.mjs');
// Not a real credential — a known-positive blob for the entropy scanner only.
const HIGH_ENTROPY_BLOB = crypto.randomBytes(72).toString('base64');
const CUSTOM_MARKER = 'NESTEDSCOPEMARKER_4417';
const CUSTOM_RULE_ID = 'CUSTOM-NESTED-001';
const UNREACHABLE = { entropy: 99, minLen: 1_000_000 };
function writeFixture(dir, { withPolicy, withIgnore }) {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'config.js'), `const payload = "${HIGH_ENTROPY_BLOB}";\nmodule.exports = { payload };\n`);
writeFileSync(join(dir, 'notes.txt'), `prefix ${CUSTOM_MARKER} suffix\n`);
writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({
rules: [{
id: CUSTOM_RULE_ID,
family: 'webshell',
severity: 'high',
pattern: 'NESTEDSCOPEMARKER_[0-9]+',
description: 'Target-supplied custom rule (must only load for the own working tree)',
}],
}));
if (withPolicy) {
mkdirSync(join(dir, '.llm-security'), { recursive: true });
writeFileSync(join(dir, '.llm-security', 'policy.json'), JSON.stringify({
entropy: { thresholds: { critical: UNREACHABLE, high: UNREACHABLE, medium: UNREACHABLE } },
sig: { custom_rules_path: 'custom-sigs.json' },
}));
}
if (withIgnore) writeFileSync(join(dir, '.llm-security-ignore'), '**\n');
}
function git(dir, args) {
const r = spawnSync('git', ['-C', dir, ...args], {
encoding: 'utf8',
env: {
...process.env,
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@example.invalid',
GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@example.invalid',
GIT_AUTHOR_DATE: '2026-01-01T00:00:00Z', GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z',
},
});
assert.equal(r.status, 0, `git ${args.join(' ')} failed: ${r.stderr}`);
}
/** A "clone": its own git root with every fixture file committed. */
function makeClone(dir, opts) {
writeFixture(dir, opts);
git(dir, ['init', '-q']);
git(dir, ['add', '-A']);
git(dir, ['commit', '-q', '-m', 'fixture']);
}
function runOrchestrator(target, cwd) {
return new Promise((resolveP) => {
const stdout = [];
const stderr = [];
const child = spawn('node', [ORCHESTRATOR, target], { cwd, timeout: 180_000, stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout.on('data', (c) => stdout.push(c));
child.stderr.on('data', (c) => stderr.push(c));
child.on('close', (code) => {
resolveP({
code: code ?? 1,
env: JSON.parse(Buffer.concat(stdout).toString('utf8')),
stderr: Buffer.concat(stderr).toString('utf8'),
});
});
});
}
const entropyFindings = (env) => env?.scanners?.entropy?.findings || [];
const customSigFindings = (env) => (env?.scanners?.sig?.findings || [])
.filter((f) => String(f.evidence || '').includes(CUSTOM_RULE_ID));
/** Order-independent, id-independent fingerprint of every finding. */
function findingKeys(env) {
const keys = [];
for (const [name, result] of Object.entries(env?.scanners || {})) {
for (const f of result.findings || []) {
keys.push(`${name}|${f.severity}|${f.title}|${f.file}|${f.line ?? ''}`);
}
}
return keys.sort();
}
describe('a clone under the caller\'s cwd is a foreign target (v8.1.1)', () => {
let root;
let work;
let homeish;
before(() => {
root = mkOwnTreeDir('nested-clone-scope-');
work = join(root, 'work');
mkdirSync(work, { recursive: true });
git(work, ['init', '-q']);
homeish = join(root, 'homeish');
mkdirSync(homeish, { recursive: true });
});
after(() => { rmSync(root, { recursive: true, force: true }); });
describe('(a) cwd = repo, target = nested clone with policy.json + `**` ignore', () => {
let withRun;
let withoutRun;
before(async () => {
// Same parent, so the only difference the scanners can see is the config.
makeClone(join(work, 'vendor', 'clone'), { withPolicy: true, withIgnore: true });
makeClone(join(work, 'vendor', 'clone-noconfig'), { withPolicy: false, withIgnore: false });
withRun = await runOrchestrator(join(work, 'vendor', 'clone'), work);
withoutRun = await runOrchestrator(join(work, 'vendor', 'clone-noconfig'), work);
});
it('the known HIGH entropy finding survives', () => {
assert.equal(entropyFindings(withRun.env).length, 1);
});
it('the clone-supplied custom SIG rule is not loaded', () => {
assert.equal(customSigFindings(withRun.env).length, 0);
});
it('nothing is suppressed', () => {
assert.ok(!withRun.env.suppressed, `suppressed must be falsy, got ${withRun.env.suppressed}`);
});
it('same verdict and same findings as the same clone without the config files', () => {
assert.equal(withRun.env.aggregate.verdict, withoutRun.env.aggregate.verdict);
assert.deepEqual(findingKeys(withRun.env), findingKeys(withoutRun.env));
});
it('stderr says both config files were not honored', () => {
assert.match(withRun.stderr, /\.llm-security-ignore.*ignored/i);
assert.match(withRun.stderr, /policy\.json.*ignored/i);
});
});
describe('(b) cwd = repo, target = plain subdir of the SAME repo (known-positive)', () => {
let run;
before(async () => {
writeFixture(join(work, 'inner'), { withPolicy: true, withIgnore: false });
run = await runOrchestrator(join(work, 'inner'), work);
});
it('the raised thresholds silence the entropy finding', () => {
assert.equal(entropyFindings(run.env).length, 0);
});
it('the custom SIG rule loads and fires', () => {
assert.equal(customSigFindings(run.env).length, 1);
});
});
describe('(c) cwd = dir with no git root, target = clone under it', () => {
let run;
before(async () => {
makeClone(join(homeish, 'clone'), { withPolicy: true, withIgnore: true });
run = await runOrchestrator(join(homeish, 'clone'), homeish);
});
it('the known HIGH entropy finding survives and nothing is suppressed', () => {
assert.equal(entropyFindings(run.env).length, 1);
assert.ok(!run.env.suppressed);
});
it('the clone-supplied custom SIG rule is not loaded', () => {
assert.equal(customSigFindings(run.env).length, 0);
});
});
});