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