forked from bai/curriculum-project-hub
feat: add curated curriculum agent skills
This commit is contained in:
@@ -62,6 +62,9 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
writeFile(join(sibling, "secret.txt"), "sibling-secret\n"),
|
||||
writeFile(serviceSecret, "platform-secret\n"),
|
||||
]);
|
||||
const untrustedSkill = join(workspace, ".claude", "skills", "untrusted");
|
||||
await mkdir(untrustedSkill, { recursive: true });
|
||||
await writeFile(join(untrustedSkill, "SKILL.md"), "---\nname: untrusted\ndescription: must never load\n---\n");
|
||||
// macOS tmpdir is reached through /var -> /private/var. Exercise the
|
||||
// sandbox with canonical paths, matching the canonical cwd returned by
|
||||
// createAgentSecurityPolicy rather than relying on a host symlink alias.
|
||||
@@ -146,6 +149,11 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
|
||||
).toBe("completed");
|
||||
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
|
||||
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
|
||||
"cph-curated:outline",
|
||||
"cph-curated:lesson-project",
|
||||
"cph-curated:data-processing-spec",
|
||||
]));
|
||||
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
|
||||
expect(toolResults).toHaveLength(2);
|
||||
const rejectedOptOut = toolResults[0];
|
||||
|
||||
@@ -51,6 +51,14 @@ describe("agent subprocess security policy", () => {
|
||||
expect(policy.env.TEMP).toBe(policy.env.TMPDIR);
|
||||
expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t"));
|
||||
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56);
|
||||
expect(policy.skillIds).toEqual([
|
||||
"cph-curated:outline",
|
||||
"cph-curated:lesson-project",
|
||||
"cph-curated:data-processing-spec",
|
||||
]);
|
||||
expect(policy.skillPluginRoot.startsWith(canonicalWorkspace)).toBe(false);
|
||||
expect(policy.sandbox.filesystem.allowRead).toContain(policy.skillPluginRoot);
|
||||
expect(policy.sandbox.filesystem.allowWrite).not.toContain(policy.skillPluginRoot);
|
||||
|
||||
expect(policy.sandbox).toMatchObject({
|
||||
enabled: true,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CURATED_SKILL_IDS,
|
||||
CURATED_SKILL_NAMES,
|
||||
CURATED_SKILL_PLUGIN_NAME,
|
||||
validateCuratedSkillPlugin,
|
||||
} from "../../src/agent/curatedSkills.js";
|
||||
|
||||
describe("validateCuratedSkillPlugin", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("accepts exactly the release-owned plugin and returns qualified skill ids", async () => {
|
||||
const root = await skillPluginFixture();
|
||||
|
||||
await expect(validateCuratedSkillPlugin(root)).resolves.toEqual({
|
||||
root,
|
||||
skillIds: CURATED_SKILL_IDS,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a curated skill is absent from the release", async () => {
|
||||
const root = await skillPluginFixture();
|
||||
await rm(join(root, "skills", "outline"), { recursive: true });
|
||||
|
||||
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated plugin entry missing: skills\/outline/);
|
||||
});
|
||||
|
||||
it("fails closed when a skill manifest name does not match the allowlist", async () => {
|
||||
const root = await skillPluginFixture();
|
||||
await writeFile(join(root, "skills", "outline", "SKILL.md"), "---\nname: other\n---\n");
|
||||
|
||||
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated skill manifest name mismatch/);
|
||||
});
|
||||
|
||||
it("rejects an extra skill directory", async () => {
|
||||
const root = await skillPluginFixture();
|
||||
await mkdir(join(root, "skills", "extra"));
|
||||
|
||||
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: skills\/extra/);
|
||||
});
|
||||
|
||||
it("rejects plugin capabilities outside the reviewed skill catalog", async () => {
|
||||
const root = await skillPluginFixture();
|
||||
await mkdir(join(root, "hooks"));
|
||||
|
||||
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: hooks/);
|
||||
});
|
||||
|
||||
async function skillPluginFixture(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-skills-"));
|
||||
roots.push(root);
|
||||
await mkdir(join(root, ".claude-plugin"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, ".claude-plugin", "plugin.json"),
|
||||
JSON.stringify({ name: CURATED_SKILL_PLUGIN_NAME }),
|
||||
);
|
||||
for (const name of CURATED_SKILL_NAMES) {
|
||||
const source = join(root, "skills", name);
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
});
|
||||
@@ -33,6 +33,16 @@ function resultMessage(sessionId: string, costUsd?: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function initMessage(skills: string[]) {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
skills,
|
||||
tools: [],
|
||||
plugins: [],
|
||||
};
|
||||
}
|
||||
|
||||
function messages(...items: unknown[]) {
|
||||
return (async function* () {
|
||||
for (const item of items) yield item;
|
||||
@@ -102,6 +112,8 @@ describe("runAgent", () => {
|
||||
permissionMode: "bypassPermissions",
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: [],
|
||||
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
|
||||
skills: ["cph-curated:outline", "cph-curated:lesson-project", "cph-curated:data-processing-spec"],
|
||||
strictMcpConfig: true,
|
||||
sandbox: expect.objectContaining({
|
||||
enabled: true,
|
||||
@@ -134,6 +146,26 @@ describe("runAgent", () => {
|
||||
expect(call?.options).not.toHaveProperty("resume");
|
||||
});
|
||||
|
||||
it("returns the skills actually reported by SDK initialization", async () => {
|
||||
queryMock.mockReturnValue(messages(
|
||||
initMessage(["cph-curated:outline"]),
|
||||
assistantMessage("fresh"),
|
||||
resultMessage("sdk-session-1"),
|
||||
));
|
||||
|
||||
const result = await runAgent({
|
||||
prompt: "写一个大纲",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
expect(result.initializedSkillIds).toEqual(["cph-curated:outline"]);
|
||||
});
|
||||
|
||||
it("maps role tool ids to the Claude SDK tool whitelist", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user