import { dirname } from "node:path"; import type { PathNormalizer } from "./path-normalizer"; import type { PermissionCheckResult, PermissionState } from "./types"; /** * Narrow interface for the raw (no-session-rules) permission checker used by * skill prompt resolution. `PermissionResolver` implements it (#478). */ export interface SkillPermissionChecker { checkPermission( surface: string, input: unknown, agentName?: string, ): PermissionCheckResult; } const AVAILABLE_SKILLS_OPEN_TAG = ""; const AVAILABLE_SKILLS_CLOSE_TAG = ""; const SKILL_BLOCK_PATTERN = "([\\s\\S]*?)<\\/skill>"; const SKILL_NAME_REGEX = /([\s\S]*?)<\/name>/; const SKILL_DESCRIPTION_REGEX = /([\s\S]*?)<\/description>/; const SKILL_LOCATION_REGEX = /([\s\S]*?)<\/location>/; type ParsedSkillPromptEntry = { name: string; description: string; location: string; }; export type SkillPromptEntry = { name: string; description: string; location: string; state: PermissionState; normalizedLocation: string; normalizedBaseDir: string; }; export type SkillPromptSection = { start: number; end: number; entries: ParsedSkillPromptEntry[]; }; function decodeXml(value: string): string { return value .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, "&"); } function encodeXml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] { const entries: ParsedSkillPromptEntry[] = []; const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g"); for (const match of sectionBody.matchAll(skillBlockRegex)) { const block = match[1]; const nameMatch = SKILL_NAME_REGEX.exec(block); const descriptionMatch = SKILL_DESCRIPTION_REGEX.exec(block); const locationMatch = SKILL_LOCATION_REGEX.exec(block); if (!nameMatch || !descriptionMatch || !locationMatch) { continue; } const name = decodeXml(nameMatch[1].trim()); const description = decodeXml(descriptionMatch[1].trim()); const location = decodeXml(locationMatch[1].trim()); if (!name || !location) { continue; } entries.push({ name, description, location }); } return entries; } export function parseAllSkillPromptSections( prompt: string, ): SkillPromptSection[] { const sections: SkillPromptSection[] = []; let searchStart = 0; while (searchStart < prompt.length) { const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG, searchStart); if (start === -1) { break; } const closeStart = prompt.indexOf( AVAILABLE_SKILLS_CLOSE_TAG, start + AVAILABLE_SKILLS_OPEN_TAG.length, ); if (closeStart === -1) { break; } const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length; const sectionBody = prompt.slice( start + AVAILABLE_SKILLS_OPEN_TAG.length, closeStart, ); sections.push({ start, end, entries: parseSkillEntries(sectionBody), }); searchStart = end; } return sections; } function resolvePermissionState( skillName: string, permissionManager: SkillPermissionChecker, agentName: string | null, cache: Map, ): PermissionState { const cachedState = cache.get(skillName); if (cachedState) { return cachedState; } const state = permissionManager.checkPermission( "skill", { name: skillName }, agentName ?? undefined, ).state; cache.set(skillName, state); return state; } function createResolvedSkillEntry( entry: ParsedSkillPromptEntry, state: PermissionState, normalizer: PathNormalizer, ): SkillPromptEntry { return { name: entry.name, description: entry.description, location: entry.location, state, normalizedLocation: normalizer.comparableValue(entry.location), normalizedBaseDir: normalizer.comparableValue(dirname(entry.location)), }; } function renderAvailableSkillsSection( entries: readonly SkillPromptEntry[], ): string { return [ AVAILABLE_SKILLS_OPEN_TAG, ...entries.flatMap((entry) => [ " ", ` ${encodeXml(entry.name)}`, ` ${encodeXml(entry.description)}`, ` ${encodeXml(entry.location)}`, " ", ]), AVAILABLE_SKILLS_CLOSE_TAG, ].join("\n"); } function removePromptRange(prompt: string, start: number, end: number): string { const beforeSection = prompt.slice(0, start).replace(/\n+$/, ""); const afterSection = prompt.slice(end); return `${beforeSection}${afterSection}`; } export function resolveSkillPromptEntries( prompt: string, permissionManager: SkillPermissionChecker, agentName: string | null, normalizer: PathNormalizer, ): { prompt: string; entries: SkillPromptEntry[] } { const sections = parseAllSkillPromptSections(prompt); if (sections.length === 0) { return { prompt, entries: [] }; } const permissionCache = new Map(); const visibleEntries: SkillPromptEntry[] = []; const replacements: Array<{ start: number; end: number; content: string }> = []; for (const section of sections) { const resolvedEntries = section.entries.map((entry) => { const state = resolvePermissionState( entry.name, permissionManager, agentName, permissionCache, ); return createResolvedSkillEntry(entry, state, normalizer); }); const visibleSectionEntries = resolvedEntries.filter( (entry) => entry.state !== "deny", ); visibleEntries.push(...visibleSectionEntries); if (visibleSectionEntries.length === resolvedEntries.length) { continue; } replacements.push({ start: section.start, end: section.end, content: visibleSectionEntries.length > 0 ? renderAvailableSkillsSection(visibleSectionEntries) : "", }); } if (replacements.length === 0) { return { prompt, entries: visibleEntries }; } let sanitizedPrompt = prompt; for (let i = replacements.length - 1; i >= 0; i--) { const replacement = replacements[i]; sanitizedPrompt = replacement.content.length > 0 ? `${sanitizedPrompt.slice(0, replacement.start)}${replacement.content}${sanitizedPrompt.slice(replacement.end)}` : removePromptRange( sanitizedPrompt, replacement.start, replacement.end, ); } return { prompt: sanitizedPrompt, entries: visibleEntries, }; } export function findSkillPathMatch( normalizedPath: string, entries: readonly SkillPromptEntry[], normalizer: PathNormalizer, ): SkillPromptEntry | null { if (!normalizedPath || entries.length === 0) { return null; } for (const entry of entries) { if ( entry.normalizedLocation && normalizedPath === entry.normalizedLocation ) { return entry; } } let bestMatch: SkillPromptEntry | null = null; for (const entry of entries) { if ( !entry.normalizedBaseDir || !normalizer.isWithinDirectory(normalizedPath, entry.normalizedBaseDir) ) { continue; } if ( !bestMatch || entry.normalizedBaseDir.length > bestMatch.normalizedBaseDir.length ) { bestMatch = entry; } } return bestMatch; }