import { resolve } from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; import { posixPathFlavor } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; import type { ScopedPermissionManager } from "#src/permission-manager"; import { findSkillPathMatch, parseAllSkillPromptSections, resolveSkillPromptEntries, type SkillPermissionChecker, } from "#src/skill-prompt-sanitizer"; import type { PermissionCheckResult } from "#src/types"; import { createManager } from "#test/helpers/manager-harness"; /** * Adapt a real `PermissionManager` to the raw `SkillPermissionChecker` * contract, mirroring how `PermissionResolver.checkPermission` delegates to * `manager.check` with a tool intent (#478). */ function asChecker(manager: ScopedPermissionManager): SkillPermissionChecker { return { checkPermission: (surface, input, agentName) => manager.check({ kind: "tool", surface, input, agentName }), }; } afterEach(() => { vi.restoreAllMocks(); }); // ── Helpers ──────────────────────────────────────────────────────────────── const CWD = "/projects/my-app"; // `findSkillPathMatch` only uses the normalizer's platform (it compares two // already-absolute paths via `isWithinDirectory`), so this CWD-baked instance // serves every call regardless of the entries' cwd. const normalizer = new PathNormalizer(posixPathFlavor, CWD); function makeManager( defaultState: "allow" | "deny" | "ask" = "allow", overrides: Record = {}, ): SkillPermissionChecker { return { checkPermission: vi.fn( (_surface: string, input: unknown): PermissionCheckResult => { const name = (input as { name?: string }).name ?? ""; const state = overrides[name] ?? defaultState; return { toolName: "skill", state, source: "tool", origin: "builtin" }; }, ), }; } function skillBlock( name: string, location = `/skills/${name}/SKILL.md`, ): string { return [ " ", ` ${name}`, ` Description of ${name}`, ` ${location}`, " ", ].join("\n"); } function availableSkillsSection(...names: string[]): string { return [ "", ...names.map((n) => skillBlock(n)), "", ].join("\n"); } // ── resolveSkillPromptEntries ─────────────────────────────────────────────── describe("resolveSkillPromptEntries", () => { test("returns unchanged prompt and empty entries when no skills section present", () => { const input = "You are a helpful assistant."; const manager = makeManager("allow"); const result = resolveSkillPromptEntries(input, manager, null, normalizer); expect(result.prompt).toBe(input); expect(result.entries).toEqual([]); expect(manager.checkPermission).not.toHaveBeenCalled(); }); test("keeps all skills when all are allowed", () => { const input = availableSkillsSection("librarian", "ask-user"); const manager = makeManager("allow"); const result = resolveSkillPromptEntries(input, manager, null, normalizer); expect(result.prompt).toContain("librarian"); expect(result.prompt).toContain("ask-user"); expect(result.entries).toHaveLength(2); }); test("removes denied skill from section", () => { const input = availableSkillsSection("librarian", "dangerous"); const manager = makeManager("allow", { dangerous: "deny" }); const result = resolveSkillPromptEntries(input, manager, null, normalizer); expect(result.prompt).toContain("librarian"); expect(result.prompt).not.toContain("dangerous"); // denied skill is excluded from returned entries expect(result.entries.map((e) => e.name)).not.toContain("dangerous"); }); test("removes entire section when all skills are denied", () => { const input = `Intro\n${availableSkillsSection("dangerous")}\nOutro`; const manager = makeManager("deny"); const result = resolveSkillPromptEntries(input, manager, null, normalizer); expect(result.prompt).not.toContain(""); expect(result.prompt).toContain("Intro"); expect(result.prompt).toContain("Outro"); expect(result.entries).toHaveLength(0); }); test("keeps ask-state skills in section and entries", () => { const input = availableSkillsSection("librarian"); const manager = makeManager("ask"); const result = resolveSkillPromptEntries(input, manager, null, normalizer); expect(result.prompt).toContain("librarian"); expect(result.entries).toHaveLength(1); expect(result.entries[0].state).toBe("ask"); }); test("delegates permission check to permissionManager for each skill", () => { const input = availableSkillsSection("alpha", "beta"); const manager = makeManager("allow"); resolveSkillPromptEntries(input, manager, null, normalizer); expect(manager.checkPermission).toHaveBeenCalledWith( "skill", { name: "alpha" }, undefined, ); expect(manager.checkPermission).toHaveBeenCalledWith( "skill", { name: "beta" }, undefined, ); }); test("passes agentName to permissionManager", () => { const input = availableSkillsSection("librarian"); const manager = makeManager("allow"); resolveSkillPromptEntries(input, manager, "my-agent", normalizer); expect(manager.checkPermission).toHaveBeenCalledWith( "skill", { name: "librarian" }, "my-agent", ); }); test("caches permission result: checkPermission called once per unique skill name", () => { // Same skill appears in two separate sections. const input = [ availableSkillsSection("librarian"), availableSkillsSection("librarian"), ].join("\n"); const manager = makeManager("allow"); resolveSkillPromptEntries(input, manager, null, normalizer); // Should only be called once despite appearing twice. expect(manager.checkPermission).toHaveBeenCalledTimes(1); }); test("resolves entry normalizedLocation relative to cwd", () => { const location = "/skills/librarian/SKILL.md"; const input = availableSkillsSection("librarian"); const manager = makeManager("allow"); const result = resolveSkillPromptEntries(input, manager, null, normalizer); expect(result.entries[0].normalizedLocation).toBe(location); expect(result.entries[0].normalizedBaseDir).toBe("/skills/librarian"); }); test("handles multi-section prompt: processes each section independently", () => { const section1 = availableSkillsSection("alpha"); const section2 = availableSkillsSection("beta"); const input = `${section1}\n${section2}`; const manager = makeManager("allow", { beta: "deny" }); const result = resolveSkillPromptEntries(input, manager, null, normalizer); expect(result.entries.map((e) => e.name)).toContain("alpha"); expect(result.entries.map((e) => e.name)).not.toContain("beta"); }); }); // ── findSkillPathMatch ────────────────────────────────────────────────────── describe("findSkillPathMatch", () => { const entries = [ { name: "librarian", description: "desc", location: "/skills/librarian/SKILL.md", state: "allow" as const, normalizedLocation: "/skills/librarian/SKILL.md", normalizedBaseDir: "/skills/librarian", }, { name: "ask-user", description: "desc", location: "/skills/ask-user/SKILL.md", state: "allow" as const, normalizedLocation: "/skills/ask-user/SKILL.md", normalizedBaseDir: "/skills/ask-user", }, ]; test("returns null for empty normalized path", () => { expect(findSkillPathMatch("", entries, normalizer)).toBeNull(); }); test("returns null for empty entries array", () => { expect( findSkillPathMatch("/skills/librarian/SKILL.md", [], normalizer), ).toBeNull(); }); test("matches exact location path", () => { const match = findSkillPathMatch( "/skills/librarian/SKILL.md", entries, normalizer, ); expect(match?.name).toBe("librarian"); }); test("matches path within skill base directory", () => { const match = findSkillPathMatch( "/skills/librarian/extra/helper.md", entries, normalizer, ); expect(match?.name).toBe("librarian"); }); test("returns null for path not within any skill directory", () => { const match = findSkillPathMatch( "/other/path/file.md", entries, normalizer, ); expect(match).toBeNull(); }); test("returns null for sibling path that shares a prefix", () => { // "/skills/librarian-extra" should not match "/skills/librarian" const match = findSkillPathMatch( "/skills/librarian-extra/SKILL.md", entries, normalizer, ); expect(match).toBeNull(); }); test("prefers longer matching base directory (most specific skill wins)", () => { const nestedEntries = [ { name: "parent", description: "desc", location: "/skills/parent/SKILL.md", state: "allow" as const, normalizedLocation: "/skills/parent/SKILL.md", normalizedBaseDir: "/skills/parent", }, { name: "child", description: "desc", location: "/skills/parent/child/SKILL.md", state: "allow" as const, normalizedLocation: "/skills/parent/child/SKILL.md", normalizedBaseDir: "/skills/parent/child", }, ]; const match = findSkillPathMatch( "/skills/parent/child/helper.md", nestedEntries, normalizer, ); expect(match?.name).toBe("child"); }); }); // --------------------------------------------------------------------------- // Moved from permission-system.test.ts catch-all (#342) // --------------------------------------------------------------------------- test("parseAllSkillPromptSections finds every available_skills block", () => { const prompt = [ "Some preamble", "", " ", " skill-one", " First skill", " /path/to/one", " ", "", "Some content between", "", " ", " skill-two", " Second skill", " /path/to/two", " ", "", "Footer", ].join("\n"); const sections = parseAllSkillPromptSections(prompt); expect(sections.length).toBe(2); expect(sections[0].entries[0]?.name).toBe("skill-one"); expect(sections[1].entries[0]?.name).toBe("skill-two"); }); test("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills block", () => { const { manager, cleanup } = createManager({ permission: { "*": "ask", skill: { "denied-skill": "deny" }, }, }); try { const prompt = [ "System prompt start", "", " ", " visible-skill", " Allowed skill", " /skills/visible/index.ts", " ", " ", " denied-skill", " Denied in first block", " /skills/blocked/one.ts", " ", "", "Agent identity section", "", " ", " denied-skill", " Denied in second block", " /skills/blocked/two.ts", " ", "", "System prompt end", ].join("\n"); const result = resolveSkillPromptEntries( prompt, asChecker(manager), null, new PathNormalizer(posixPathFlavor, "/cwd"), ); expect(result.prompt).not.toContain("denied-skill"); expect(result.prompt).toContain("visible-skill"); expect((result.prompt.match(//g) ?? []).length).toBe(1); expect(result.entries.map((entry) => entry.name)).toEqual([ "visible-skill", ]); } finally { cleanup(); } }); test("REGRESSION: resolveSkillPromptEntries keeps only visible skills available for path matching", () => { const { manager, cleanup } = createManager({ permission: { "*": "ask", skill: { "blocked-skill": "deny" }, }, }); try { const prompt = [ "System prompt start", "", " ", " blocked-skill", " Blocked skill", " @./skills/blocked/entry.ts", " ", "", "Middle section", "", " ", " visible-skill", " Visible skill", " @./skills/visible/entry.ts", " ", "", "System prompt end", ].join("\n"); const result = resolveSkillPromptEntries( prompt, asChecker(manager), null, new PathNormalizer(posixPathFlavor, "/cwd"), ); const visiblePath = resolve("/cwd", "./skills/visible/file.ts"); const blockedPath = resolve("/cwd", "./skills/blocked/file.ts"); const matchedVisibleSkill = findSkillPathMatch( process.platform === "win32" ? visiblePath.toLowerCase() : visiblePath, result.entries, normalizer, ); const matchedBlockedSkill = findSkillPathMatch( process.platform === "win32" ? blockedPath.toLowerCase() : blockedPath, result.entries, normalizer, ); expect(matchedVisibleSkill?.name).toBe("visible-skill"); expect(matchedBlockedSkill).toBe(null); } finally { cleanup(); } });