feat(llm-security): v8 Phase 5 step 2 - commons-loader.mjs [skip-docs]

Thin, sync-read JSON artifact loader for the future vendored
llm-security-commons subtree, modeled on signature-scanner.mjs's
loadRules()/loadCustomRules() pair: process-cached, graceful-empty
fallback on any read/parse error, and policy-extensible via a
`commons.root` policy value (mirrors sig.custom_rules_path).

Unit-tested now against a local fixture — Phase 4 (commons repo
creation, gated on the operator creating the Forgejo remote) hasn't
run yet, so the default `shared/` vendor path doesn't exist in this
checkout. That "not vendored yet" case is itself asserted: the loader
must degrade to the caller's fallback, not crash.

Not wired to any consumer yet (that's Phase 5 step 4, table-by-table
behind the golden gate). No CLI/hook/scanner-visible behaviour exists
to document. Golden baseline unchanged; full suite 2063/2063 (2053 +
10 new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAYkRaBXT6tmWXTQAi1ZBg
This commit is contained in:
Kjell Tore Guttormsen 2026-08-09 14:06:37 +02:00
commit 69cad7c973
2 changed files with 208 additions and 0 deletions

View file

@ -0,0 +1,92 @@
// commons-loader.mjs — Reads vendored llm-security-commons JSON artifacts.
//
// v8 Phase 5 step 2: this loader is built and unit-tested now, against a
// local fixture, ahead of Phase 4 (llm-security-commons repo creation +
// vendoring — see docs/commons-extraction-plan.local.md). Consumers
// (injection-patterns.mjs, severity.mjs, string-utils.mjs, ...) switch from
// hardcoded tables to this loader in Phase 5 step 4, table-by-table, behind
// the golden gate.
//
// Modeled on signature-scanner.mjs's loadRules()/loadCustomRules() pair:
// hooks run per-tool-call in fresh zero-dep processes, so resolution must be
// a fast synchronous read of the vendored copy, never network. Cached once
// per process. A missing/unvendored commons dir degrades to the caller's
// fallback rather than crashing a hook — the same graceful-empty contract
// loadRules() uses for a missing knowledge/signatures.json.
//
// Zero external dependencies — Node.js builtins only.
import { readFileSync } from 'node:fs';
import { join, dirname, isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getPolicyValue } from './policy-loader.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Default vendored location: llm-security-commons is pulled in as a
// pull-only subtree at the repo root, mirroring the portfolio-optimiser
// family's `shared/` convention (docs/commons-extraction-plan.local.md).
const DEFAULT_COMMONS_ROOT = join(__dirname, '..', '..', 'shared');
// Cached, parsed artifacts, keyed by resolved absolute file path.
const _cache = new Map();
/**
* Resolve the commons root directory.
* Precedence: explicit `commonsRoot` option > `commons.root` policy value
* (relative paths resolve against `targetPath`) > the default vendored path.
* @param {string|undefined} targetPath
* @param {string|undefined} commonsRoot
* @returns {string}
*/
function resolveCommonsRoot(targetPath, commonsRoot) {
if (commonsRoot) return commonsRoot;
const policyRoot = getPolicyValue('commons', 'root', null, targetPath);
if (policyRoot && typeof policyRoot === 'string') {
return isAbsolute(policyRoot) ? policyRoot : resolve(targetPath || process.cwd(), policyRoot);
}
return DEFAULT_COMMONS_ROOT;
}
/**
* Load and parse one commons JSON artifact.
* Graceful fallback on any read/parse error an unvendored, missing, or
* invalid commons artifact must never crash a hook or scanner.
*
* @param {string} artifactPath - relative path under the commons root,
* without the `.json` extension (e.g. `'lexicon/injection-lexicon'`).
* @param {object} [opts]
* @param {*} [opts.fallback] - value returned on any load/parse failure
* (default: `null`). Callers pick the shape-appropriate empty value
* (`[]`, `{}`, ...) the same way loadRules() falls back to `[]`.
* @param {string} [opts.commonsRoot] - explicit commons root, overriding
* policy and the default (for tests and one-off callers).
* @param {string} [opts.targetPath] - scan root used to resolve a
* policy-relative `commons.root` and to locate `.llm-security/policy.json`.
* @returns {*} Parsed JSON, or `opts.fallback` on failure.
*/
export function loadArtifact(artifactPath, opts = {}) {
const { fallback = null, commonsRoot, targetPath } = opts;
const root = resolveCommonsRoot(targetPath, commonsRoot);
const filePath = join(root, `${artifactPath}.json`);
if (_cache.has(filePath)) return _cache.get(filePath);
let result;
try {
const raw = readFileSync(filePath, 'utf8');
result = JSON.parse(raw);
} catch {
result = fallback; // graceful: unvendored/missing/invalid -> caller's empty shape
}
_cache.set(filePath, result);
return result;
}
/**
* Reset the artifact cache (for testing only).
*/
export function _resetCacheForTest() {
_cache.clear();
}