// Legend loader - loads and caches legend data from bundled YAML files
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import yaml from 'yaml';
import { safeString } from '../utils/sanitize.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Cache for loaded legends
let legendsCache = null;
// Maximum file size to prevent DoS (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024;
// Maximum number of legends to load
const MAX_LEGENDS = 500;
/**
* Get the path to the bundled legends directory
* SECURITY: Only load from package directory unless explicitly allowed
*/
function getLegendsDir() {
// Check for explicit custom directory (opt-in only)
const customDir = process.env.LEGENDS_MCP_LEGENDS_DIR;
if (customDir) {
if (fs.existsSync(customDir)) {
console.error(`[legends-mcp] Using custom legends directory: ${customDir}`);
return customDir;
}
throw new Error(`Custom legends directory not found: ${customDir}`);
}
// SECURITY: Only load from package directory (relative to compiled file)
// Do NOT fall back to process.cwd() to prevent loading untrusted legends
const possiblePaths = [
path.join(__dirname, '..', '..', 'legends'),
path.join(__dirname, '..', 'legends'),
];
// Only allow CWD if explicitly opted in (for development)
if (process.env.LEGENDS_MCP_ALLOW_CWD === '1') {
possiblePaths.push(path.join(process.cwd(), 'legends'));
console.error('[legends-mcp] WARNING: CWD loading enabled - only use in trusted environments');
}
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
return p;
}
}
throw new Error('Could not find legends directory. Ensure the package is properly installed. ' +
'For custom directories, set LEGENDS_MCP_LEGENDS_DIR environment variable.');
}
/**
* Validate a legend file before loading
*/
function validateLegendFile(filePath) {
try {
const stats = fs.statSync(filePath);
if (stats.size > MAX_FILE_SIZE) {
console.error(`[legends-mcp] Skipping oversized file: ${filePath} (${stats.size} bytes)`);
return false;
}
return true;
}
catch {
return false;
}
}
/**
* Validate required fields in a legend
*/
function validateLegend(data, filePath) {
if (!data || typeof data !== 'object') {
console.error(`[legends-mcp] Invalid legend data in ${filePath}`);
return false;
}
// Must have at least a name or id
if (!data.name && !data.id) {
console.error(`[legends-mcp] Legend missing name/id in ${filePath}`);
return false;
}
return true;
}
/**
* Load all legends from YAML files
*/
export function loadLegends() {
if (legendsCache) {
return legendsCache;
}
const legendsDir = getLegendsDir();
const legends = new Map();
const entries = fs.readdirSync(legendsDir, { withFileTypes: true });
let loadedCount = 0;
for (const entry of entries) {
if (!entry.isDirectory())
continue;
if (loadedCount >= MAX_LEGENDS) {
console.error(`[legends-mcp] Maximum legends limit reached (${MAX_LEGENDS})`);
break;
}
const skillPath = path.join(legendsDir, entry.name, 'skill.yaml');
if (!fs.existsSync(skillPath))
continue;
// Validate file before loading
if (!validateLegendFile(skillPath))
continue;
try {
const content = fs.readFileSync(skillPath, 'utf-8');
const data = yaml.parse(content);
// Validate legend data
if (!validateLegend(data, skillPath))
continue;
// Ensure required fields with safe defaults
if (!data.id)
data.id = entry.name;
if (!data.category)
data.category = 'legends';
if (!data.name)
data.name = entry.name;
if (!data.description)
data.description = '';
legends.set(data.id, data);
loadedCount++;
}
catch (error) {
console.error(`[legends-mcp] Error loading ${skillPath}:`, error);
}
}
legendsCache = legends;
return legends;
}
/**
* Get all legends
*/
export function getAllLegends() {
const legends = loadLegends();
return Array.from(legends.values());
}
/**
* Get a specific legend by ID
*/
export function getLegendById(id) {
const legends = loadLegends();
return legends.get(id);
}
/**
* Get legend summaries (lightweight list)
*/
export function getLegendSummaries() {
const legends = getAllLegends();
return legends.map(l => ({
id: l.id,
name: safeString(l.name, l.id),
description: safeString(l.description, ''),
expertise: l.owns || [],
tags: l.tags || [],
}));
}
/**
* Search legends by query
* SECURITY: Uses safe string handling to prevent crashes from malformed data
*/
export function searchLegends(query) {
const legends = getAllLegends();
const q = safeString(query, '').toLowerCase();
if (!q)
return [];
return legends.filter(l => {
// Safe string comparisons with fallbacks
const name = safeString(l.name, '').toLowerCase();
const description = safeString(l.description, '').toLowerCase();
const tags = (l.tags || []).map(t => safeString(t, '').toLowerCase());
const owns = (l.owns || []).map(o => safeString(o, '').toLowerCase());
return (name.includes(q) ||
description.includes(q) ||
tags.some(t => t.includes(q)) ||
owns.some(o => o.includes(q)));
});
}
/**
* Get legend count
*/
export function getLegendCount() {
return loadLegends().size;
}
/**
* Clear the legends cache (for testing)
*/
export function clearLegendsCache() {
legendsCache = null;
}
//# sourceMappingURL=loader.js.map