import { dirname } from "node:path";
import { isPathWithinDirectory, normalizePathForComparison } from "./common.js";
import { PermissionManager } from "./permission-manager.js";
import type { PermissionState } from "./types.js";
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 = block.match(SKILL_NAME_REGEX);
const descriptionMatch = block.match(SKILL_DESCRIPTION_REGEX);
const locationMatch = block.match(SKILL_LOCATION_REGEX);
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 parseSkillPromptSection(prompt: string): SkillPromptSection | null {
const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG);
if (start === -1) {
return null;
}
const closeStart = prompt.indexOf(AVAILABLE_SKILLS_CLOSE_TAG, start + AVAILABLE_SKILLS_OPEN_TAG.length);
if (closeStart === -1) {
return null;
}
const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length;
const sectionBody = prompt.slice(start + AVAILABLE_SKILLS_OPEN_TAG.length, closeStart);
return {
start,
end,
entries: parseSkillEntries(sectionBody),
};
}
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: PermissionManager,
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,
cwd: string,
): SkillPromptEntry {
return {
name: entry.name,
description: entry.description,
location: entry.location,
state,
normalizedLocation: normalizePathForComparison(entry.location, cwd),
normalizedBaseDir: normalizePathForComparison(dirname(entry.location), cwd),
};
}
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}`;
}
function lineContainsBacktickedHiddenSkill(line: string, hiddenSkillNames: ReadonlySet): boolean {
if (hiddenSkillNames.size === 0 || !line.includes("`")) {
return false;
}
for (const match of line.matchAll(/`([^`]+)`/g)) {
const skillName = decodeXml(match[1]?.trim() ?? "");
if (skillName && hiddenSkillNames.has(skillName)) {
return true;
}
}
return false;
}
function isStructuredSkillReferenceLine(line: string): boolean {
const trimmed = line.trim();
return trimmed.startsWith("|") || /^[-*+]\s+/.test(trimmed);
}
function pruneHiddenStructuredSkillReferences(prompt: string, hiddenSkillNames: ReadonlySet): string {
if (hiddenSkillNames.size === 0) {
return prompt;
}
const lines = prompt.split("\n");
let removed = false;
const prunedLines = lines.filter((line) => {
if (isStructuredSkillReferenceLine(line) && lineContainsBacktickedHiddenSkill(line, hiddenSkillNames)) {
removed = true;
return false;
}
return true;
});
return removed ? prunedLines.join("\n").replace(/\n{3,}/g, "\n\n") : prompt;
}
export function resolveSkillPromptEntries(
prompt: string,
permissionManager: PermissionManager,
agentName: string | null,
cwd: string,
): { prompt: string; entries: SkillPromptEntry[] } {
const sections = parseAllSkillPromptSections(prompt);
if (sections.length === 0) {
return { prompt, entries: [] };
}
const permissionCache = new Map();
const enforcementEntries: SkillPromptEntry[] = [];
const hiddenSkillNames = new Set();
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, cwd);
});
enforcementEntries.push(...resolvedEntries);
// The system prompt is an advertised capability list, so only fully allowed
// skills should be visible. Ask/deny skills remain tracked for read-path
// enforcement but are hidden to avoid context pollution.
const visibleSectionEntries = resolvedEntries.filter((entry) => entry.state === "allow");
for (const entry of resolvedEntries) {
if (entry.state !== "allow") {
hiddenSkillNames.add(entry.name);
}
}
if (visibleSectionEntries.length === resolvedEntries.length) {
continue;
}
replacements.push({
start: section.start,
end: section.end,
content: visibleSectionEntries.length > 0 ? renderAvailableSkillsSection(visibleSectionEntries) : "",
});
}
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);
}
sanitizedPrompt = pruneHiddenStructuredSkillReferences(sanitizedPrompt, hiddenSkillNames);
return {
prompt: sanitizedPrompt,
entries: enforcementEntries,
};
}
export function findSkillPathMatch(normalizedPath: string, entries: readonly SkillPromptEntry[]): 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 || !isPathWithinDirectory(normalizedPath, entry.normalizedBaseDir)) {
continue;
}
if (!bestMatch || entry.normalizedBaseDir.length > bestMatch.normalizedBaseDir.length) {
bestMatch = entry;
}
}
return bestMatch;
}