import { dirname } from "node:path"; import type { PathNormalizer } from "#src/path/path-normalizer"; import type { PermissionCheckResult, PermissionState } from "#src/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 = { entries: ParsedSkillPromptEntry[]; }; function decodeXml(value: string): string { return value .replace(/</g, "<") .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({ 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)), }; } /** * The skills listed in the prompt's `` catalogues that * policy does not deny, in catalogue order: what skill path matching may * treat as a skill's files. Edits no prompt text. */ export function visibleSkillPromptEntries( prompt: string, permissionManager: SkillPermissionChecker, agentName: string | null, normalizer: PathNormalizer, ): SkillPromptEntry[] { const permissionCache = new Map(); return parseAllSkillPromptSections(prompt) .flatMap((section) => section.entries) .map((entry) => createResolvedSkillEntry( entry, resolvePermissionState( entry.name, permissionManager, agentName, permissionCache, ), normalizer, ), ) .filter((entry) => entry.state !== "deny"); } /** * The skills policy does not deny, judged by name, in their original order. * * Judging the skill list itself, rather than a catalogue rendered from it, * keeps the answer independent of whether that catalogue has been rendered yet. */ export function withoutDeniedSkills( skills: readonly T[], permissionManager: SkillPermissionChecker, agentName: string | null, ): T[] { const permissionCache = new Map(); return skills.filter( (skill) => resolvePermissionState( skill.name, permissionManager, agentName, permissionCache, ) !== "deny", ); } 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; }