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

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