// 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(): 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. // // Deliberately NOT policy-driven, unlike loadCustomRules()'s // sig.custom_rules_path: this plugin scans untrusted cloned repos, and // `.llm-security/policy.json` lives in the *scanned target*. A // custom_rules_path override can only ever add findings a hostile target // supplies; a commons-root override would let that target *replace* this // plugin's own detection corpus wholesale, with graceful-empty fallback // making the substitution silent. The commons location is this plugin's own // concern, not the scan target's — so the only override is the explicit // `commonsRoot` option (tests, a local commons dev checkout), resolved from // this file's own location, never from policy or the scan target. // // Zero external dependencies — Node.js builtins only. import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); // Default vendored location: llm-security-commons is pulled in as a // pull-only subtree under scanners/, which is the only source directory // `package.json`'s `files` allowlist ships (bin/, scanners/, knowledge/ — // scripts/ and tests/ do not publish, and neither would a repo-root dir). const DEFAULT_COMMONS_ROOT = join(__dirname, '..', 'commons'); // Cached parsed artifacts, keyed by resolved absolute file path. A failed // load caches this sentinel (not the caller's fallback value) so that two // callers requesting the same missing artifact with different fallback // shapes (`[]` vs `{}`) each still get their own fallback back, rather than // the first caller's shape leaking to the second. const _cache = new Map(); const LOAD_FAILED = Symbol('commons-loader:load-failed'); /** Recursively freeze a parsed JSON value so callers cannot mutate a shared cached table. */ function deepFreeze(value) { if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { Object.freeze(value); for (const key of Object.keys(value)) deepFreeze(value[key]); } return value; } /** * 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. The returned * value is deep-frozen: every caller shares the same cached object, so * mutating it would silently leak across callers/targets; freezing makes * that throw instead. * * @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 the * default vendored path (for tests and a local commons dev checkout). * @returns {*} Parsed, frozen JSON, or `opts.fallback` on failure. */ export function loadArtifact(artifactPath, opts = {}) { const { fallback = null, commonsRoot } = opts; const root = commonsRoot || DEFAULT_COMMONS_ROOT; const filePath = join(root, `${artifactPath}.json`); if (_cache.has(filePath)) { const cached = _cache.get(filePath); return cached === LOAD_FAILED ? fallback : cached; } let result; try { const raw = readFileSync(filePath, 'utf8'); result = deepFreeze(JSON.parse(raw)); } catch { _cache.set(filePath, LOAD_FAILED); // graceful: unvendored/missing/invalid -> caller's fallback return fallback; } _cache.set(filePath, result); return result; } /** * Reset the artifact cache (for testing only). */ export function _resetCacheForTest() { _cache.clear(); }