fix(policy): read policy.json only from the caller's own working tree
loadPolicy() read .llm-security/policy.json from whatever root it was
given, and every scanner passes the SCANNED TARGET: scan-orchestrator
(policyRoot = resolve(args.target)), entropy-scanner (thresholds and
suppression patterns), signature-scanner (sig.custom_rules_path and
enabled_families), trigger-scanner (phrase lists) and ast-taint-scanner
(enabled, python_path). A foreign/cloned target could raise its own
entropy thresholds, disable SIG families, supply its own SIG ruleset or
name the interpreter the AST scanner spawns — configuring the scan of
itself. Same defect class as S3b's .llm-security-ignore fix.
Chosen: move isOwnWorkingTree() to scanners/lib/own-working-tree.mjs (one
copy, reused by the orchestrator's ignore-file check) and make
loadPolicy() refuse an EXPLICIT root that is not the caller's own tree —
defaults plus one stderr line, same form as S3b — because one rule in one
function covers every scanner and a future call site cannot forget it.
The IMPLICIT root (CLAUDE_PROJECT_ROOT/cwd, what every hook uses) is the
caller's own project by construction and is read as before.
entropy-scanner's calibration.policy_source no longer reports an ignored
file as its source.
New tests/scanners/policy-scope.test.mjs was red on 0d37f5a (foreign
target: entropy finding silenced, custom SIG rule loaded, findings differ
from the same tree without policy.json, no stderr line) and is green now;
its own-tree scenario (known-positive) is green before and after. The 15
existing policy tests that placed own-tree fixtures under os.tmpdir() now
use tests/helpers/own-tree.mjs (fixture under $HOME, cwd set to it).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d37f5a628
commit
6d0f3c31fc
12 changed files with 364 additions and 50 deletions
38
scanners/lib/own-working-tree.mjs
Normal file
38
scanners/lib/own-working-tree.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// 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), 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.
|
||||
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
return resolvedTarget === resolvedCwd || resolvedTarget.startsWith(resolvedCwd + sep);
|
||||
}
|
||||
|
|
@ -3,8 +3,9 @@
|
|||
// matching existing hardcoded behavior when no policy file exists.
|
||||
// Zero external dependencies.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { isOwnWorkingTree } from './own-working-tree.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default policy — matches all existing hardcoded values exactly
|
||||
|
|
@ -139,6 +140,15 @@ function deepMerge(target, source) {
|
|||
* Returns defaults if no policy file exists or if parsing fails.
|
||||
* Cached per project root (per process).
|
||||
*
|
||||
* S3c (v8.1.0, 2026-09-22): an EXPLICIT root is what the scanners pass — the
|
||||
* scanned target. It is read only when it is the caller's own working tree
|
||||
* (lib/own-working-tree.mjs, the rule S3b applied to .llm-security-ignore);
|
||||
* a foreign/cloned target gets the defaults and one stderr line, so it can
|
||||
* neither silence its own findings nor supply its own SIG ruleset
|
||||
* (`sig.custom_rules_path`) or AST interpreter (`ast.python_path`). The
|
||||
* IMPLICIT root (CLAUDE_PROJECT_ROOT or cwd — what every hook uses) is the
|
||||
* caller's own project by construction and is read as before.
|
||||
*
|
||||
* @param {string} [projectRoot] - Explicit root, or derived from env/cwd
|
||||
* @returns {object} Merged policy with defaults
|
||||
*/
|
||||
|
|
@ -150,6 +160,18 @@ export function loadPolicy(projectRoot) {
|
|||
const policyPath = join(root, '.llm-security', 'policy.json');
|
||||
let policy;
|
||||
|
||||
if (projectRoot && !isOwnWorkingTree(root)) {
|
||||
if (existsSync(policyPath)) {
|
||||
console.error(
|
||||
`[policy] ${policyPath}: policy.json was found but is ignored — ` +
|
||||
`the scanned target is not the caller's own working directory (foreign/cloned target)`
|
||||
);
|
||||
}
|
||||
policy = { ...DEFAULT_POLICY };
|
||||
cache.set(root, policy);
|
||||
return policy;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(policyPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue