mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
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<string, "allow" | "deny" | "ask"> = {},
|
||||
): 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 [
|
||||
" <skill>",
|
||||
` <name>${name}</name>`,
|
||||
` <description>Description of ${name}</description>`,
|
||||
` <location>${location}</location>`,
|
||||
" </skill>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function availableSkillsSection(...names: string[]): string {
|
||||
return [
|
||||
"<available_skills>",
|
||||
...names.map((n) => skillBlock(n)),
|
||||
"</available_skills>",
|
||||
].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("<available_skills>");
|
||||
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",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <name>skill-one</name>",
|
||||
" <description>First skill</description>",
|
||||
" <location>/path/to/one</location>",
|
||||
" </skill>",
|
||||
"</available_skills>",
|
||||
"Some content between",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <name>skill-two</name>",
|
||||
" <description>Second skill</description>",
|
||||
" <location>/path/to/two</location>",
|
||||
" </skill>",
|
||||
"</available_skills>",
|
||||
"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",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <name>visible-skill</name>",
|
||||
" <description>Allowed skill</description>",
|
||||
" <location>/skills/visible/index.ts</location>",
|
||||
" </skill>",
|
||||
" <skill>",
|
||||
" <name>denied-skill</name>",
|
||||
" <description>Denied in first block</description>",
|
||||
" <location>/skills/blocked/one.ts</location>",
|
||||
" </skill>",
|
||||
"</available_skills>",
|
||||
"Agent identity section",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <name>denied-skill</name>",
|
||||
" <description>Denied in second block</description>",
|
||||
" <location>/skills/blocked/two.ts</location>",
|
||||
" </skill>",
|
||||
"</available_skills>",
|
||||
"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(/<available_skills>/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",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <name>blocked-skill</name>",
|
||||
" <description>Blocked skill</description>",
|
||||
" <location>@./skills/blocked/entry.ts</location>",
|
||||
" </skill>",
|
||||
"</available_skills>",
|
||||
"Middle section",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <name>visible-skill</name>",
|
||||
" <description>Visible skill</description>",
|
||||
" <location>@./skills/visible/entry.ts</location>",
|
||||
" </skill>",
|
||||
"</available_skills>",
|
||||
"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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user