mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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 = "<available_skills>";
|
||||
const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
|
||||
const SKILL_BLOCK_PATTERN = "<skill>([\\s\\S]*?)<\\/skill>";
|
||||
const SKILL_NAME_REGEX = /<name>([\s\S]*?)<\/name>/;
|
||||
const SKILL_DESCRIPTION_REGEX = /<description>([\s\S]*?)<\/description>/;
|
||||
const SKILL_LOCATION_REGEX = /<location>([\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, """)
|
||||
.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<string, PermissionState>,
|
||||
): 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) => [
|
||||
" <skill>",
|
||||
` <name>${encodeXml(entry.name)}</name>`,
|
||||
` <description>${encodeXml(entry.description)}</description>`,
|
||||
` <location>${encodeXml(entry.location)}</location>`,
|
||||
" </skill>",
|
||||
]),
|
||||
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<string, PermissionState>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user