#33 the frontmatter parser collected a block-scalar body (description: |) but did not skip it, so an indented name:/allowed_tools: inside the body re-matched as a top-level key and overrode the real values TRG-shadow and permission checks depend on; the parser now consumes block-scalar bodies as opaque content. #32 block-scalar headers carrying indentation/chomping indicators (|2, >-, |-2) were not recognized, so their bodies never reached the run: injection sink; now matched via a proper indicator/chomping regex. #43 the B4 actor auth-bypass detector inspected only braced ${{ }} expressions, missing the canonical bare 'if: github.actor == ...' form (Synacktiv Dependabot-spoof false negative); bare if: expressions now emit a synthetic event the detector reads. Suite 2004/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
93 lines
3.5 KiB
JavaScript
93 lines
3.5 KiB
JavaScript
// yaml-frontmatter.mjs — Regex-based YAML frontmatter parser
|
|
// Handles Claude Code plugin command/agent/skill frontmatter.
|
|
// Zero dependencies.
|
|
|
|
/**
|
|
* Parse YAML frontmatter from a markdown file.
|
|
* Returns null if no frontmatter found.
|
|
*
|
|
* @param {string} content - File content
|
|
* @returns {{ name?: string, description?: string, model?: string, color?: string,
|
|
* tools?: string[], allowed_tools?: string[] } | null}
|
|
*/
|
|
export function parseFrontmatter(content) {
|
|
// Match --- delimited block at start of file
|
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
if (!match) return null;
|
|
|
|
const block = match[1];
|
|
const result = {};
|
|
|
|
// Parse simple key: value pairs
|
|
const lines = block.split('\n');
|
|
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
const line = lines[lineIdx];
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
|
|
// Handle key: value
|
|
const kvMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
|
|
if (!kvMatch) continue;
|
|
|
|
const [, key, rawValue] = kvMatch;
|
|
let value = rawValue.trim();
|
|
|
|
// Strip quotes
|
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
|
|
// Handle inline arrays: [Read, Write, Bash]
|
|
if (value.startsWith('[') && value.endsWith(']')) {
|
|
value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
|
|
}
|
|
|
|
// Handle multi-line description with |
|
|
if (value === '|' || value === '>') {
|
|
const descLines = [];
|
|
for (let i = lineIdx + 1; i < lines.length; i++) {
|
|
const dLine = lines[i];
|
|
if (/^\S/.test(dLine) && !dLine.startsWith(' ') && !dLine.startsWith('\t')) break;
|
|
descLines.push(dLine.replace(/^ /, ''));
|
|
// Consume the body line — block-scalar content is an opaque
|
|
// string, never re-parsed as key: value pairs (#33).
|
|
lineIdx = i;
|
|
}
|
|
value = descLines.join('\n').trim();
|
|
}
|
|
|
|
// Normalize key names
|
|
const normalizedKey = key.replace(/-/g, '_');
|
|
result[normalizedKey] = value;
|
|
}
|
|
|
|
// Parse tools from allowed-tools (comma-separated string) or tools (array)
|
|
if (typeof result.allowed_tools === 'string') {
|
|
result.allowed_tools = result.allowed_tools.split(',').map(s => s.trim());
|
|
}
|
|
if (typeof result.tools === 'string') {
|
|
result.tools = result.tools.split(',').map(s => s.trim());
|
|
}
|
|
|
|
return Object.keys(result).length > 0 ? result : null;
|
|
}
|
|
|
|
/**
|
|
* Classify a plugin file by its path and frontmatter.
|
|
* @param {string} relPath - Relative path within plugin
|
|
* @param {object|null} frontmatter - Parsed frontmatter
|
|
* @returns {'command' | 'agent' | 'skill' | 'hook-config' | 'knowledge' | 'template' | 'unknown'}
|
|
*/
|
|
export function classifyPluginFile(relPath, frontmatter) {
|
|
const lower = relPath.toLowerCase();
|
|
if (lower.includes('/commands/') || lower.startsWith('commands/')) return 'command';
|
|
if (lower.includes('/agents/') || lower.startsWith('agents/')) return 'agent';
|
|
if (lower.includes('/skills/') || lower.startsWith('skills/') || lower.endsWith('skill.md')) return 'skill';
|
|
if (lower.endsWith('hooks.json') || lower.includes('/hooks/')) return 'hook-config';
|
|
if (lower.includes('/knowledge/') || lower.startsWith('knowledge/')) return 'knowledge';
|
|
if (lower.includes('/templates/') || lower.startsWith('templates/')) return 'template';
|
|
if (frontmatter?.name && frontmatter?.allowed_tools) return 'command';
|
|
if (frontmatter?.name && frontmatter?.tools) return 'agent';
|
|
return 'unknown';
|
|
}
|