feat: add curated curriculum agent skills

This commit is contained in:
2026-07-11 12:22:01 +08:00
parent 1a892ccb54
commit 96e120e02c
28 changed files with 1785 additions and 6 deletions
+87
View File
@@ -0,0 +1,87 @@
import { lstat, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
export const CURATED_SKILL_PLUGIN_NAME = "cph-curated";
export const CURATED_SKILL_NAMES = [
"outline",
"lesson-project",
"data-processing-spec",
] as const;
export const CURATED_SKILL_IDS = CURATED_SKILL_NAMES.map(
(name) => `${CURATED_SKILL_PLUGIN_NAME}:${name}`,
);
export interface CuratedSkillPlugin {
readonly root: string;
readonly skillIds: readonly string[];
}
/** Validate the immutable, release-owned plugin before exposing it read-only. */
export async function validateCuratedSkillPlugin(
root = curatedSkillPluginRoot(),
): Promise<CuratedSkillPlugin> {
await assertExactDirectoryEntries(root, [".claude-plugin", "skills"], "");
await assertExactDirectoryEntries(join(root, ".claude-plugin"), ["plugin.json"], ".claude-plugin");
await assertExactDirectoryEntries(join(root, "skills"), CURATED_SKILL_NAMES, "skills");
const pluginManifest = join(root, ".claude-plugin", "plugin.json");
let pluginName: unknown;
try {
const stat = await lstat(pluginManifest);
if (!stat.isFile()) throw new Error(`not a regular file: ${pluginManifest}`);
pluginName = JSON.parse(await readFile(pluginManifest, "utf8")).name;
} catch (error) {
throw new Error(`curated skill plugin manifest invalid: ${pluginManifest}`, { cause: error });
}
if (pluginName !== CURATED_SKILL_PLUGIN_NAME) {
throw new Error(`curated skill plugin name mismatch: expected ${CURATED_SKILL_PLUGIN_NAME}, got ${String(pluginName)}`);
}
for (const name of CURATED_SKILL_NAMES) {
const manifest = join(root, "skills", name, "SKILL.md");
try {
const stat = await lstat(manifest);
if (!stat.isFile()) throw new Error(`not a regular file: ${manifest}`);
const contents = await readFile(manifest, "utf8");
const declaredName = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(contents)?.[1]?.trim();
if (declaredName !== name) {
throw new Error(`curated skill manifest name mismatch: expected ${name}, got ${declaredName ?? "missing"}`);
}
} catch (error) {
if (error instanceof Error && error.message.startsWith("curated skill manifest name mismatch:")) throw error;
throw new Error(`curated skill source missing: ${manifest}`, { cause: error });
}
}
return { root, skillIds: CURATED_SKILL_IDS };
}
export function curatedSkillPluginRoot(): string {
return fileURLToPath(new URL("../../curated-skills-plugin/", import.meta.url));
}
async function assertExactDirectoryEntries(
directory: string,
allowedNames: readonly string[],
relativeDirectory: string,
): Promise<void> {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
throw new Error(`curated plugin directory missing: ${directory}`, { cause: error });
}
const allowed = new Set(allowedNames);
for (const entry of entries) {
if (!allowed.has(entry.name)) {
const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`;
throw new Error(`unexpected curated plugin entry: ${relativePath}`);
}
}
for (const name of allowedNames) {
if (!entries.some((entry) => entry.name === name)) {
const relativePath = relativeDirectory === "" ? name : `${relativeDirectory}/${name}`;
throw new Error(`curated plugin entry missing: ${relativePath}`);
}
}
}
+14 -1
View File
@@ -29,7 +29,7 @@
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant.
*/
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js";
import { createAgentSecurityPolicy } from "./security.js";
@@ -91,6 +91,8 @@ export interface RunResult {
readonly costUsd?: number | undefined;
readonly numTurns: number;
readonly sdkSessionId?: string | undefined;
/** Skill ids reported by the SDK init event, not merely requested options. */
readonly initializedSkillIds?: readonly string[] | undefined;
readonly error?: string;
}
@@ -131,6 +133,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let costUsd: number | undefined;
let numTurns = 0;
let sdkSessionId: string | undefined;
let initializedSkillIds: readonly string[] | undefined;
let error: string | undefined;
try {
await persistAgentMessage(req, "user", req.prompt);
@@ -166,6 +169,8 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// The project workspace is untrusted input. Do not load user/project
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [],
plugins: [{ type: "local", path: security.skillPluginRoot, skipMcpDiscovery: true }],
skills: [...security.skillIds],
strictMcpConfig: true,
// Claude Code 2.1.202 can honor the per-call opt-out despite
// sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at
@@ -191,6 +196,12 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
for await (const message of conversation) {
switch (message.type) {
case "system": {
if (message.subtype === "init") {
initializedSkillIds = [...(message as SDKSystemMessage).skills];
}
break;
}
case "stream_event": {
const evt = (message as SDKPartialAssistantMessage).event;
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
@@ -287,6 +298,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(error !== undefined ? { error } : {}),
};
} catch (e) {
@@ -298,6 +310,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
};
}
+7 -1
View File
@@ -1,6 +1,7 @@
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { validateCuratedSkillPlugin } from "./curatedSkills.js";
const PROVIDER_ENV_KEYS = new Set([
"ANTHROPIC_BASE_URL",
@@ -60,6 +61,8 @@ export interface AgentSecurityPolicy {
readonly cwd: string;
readonly workspaceRoot: string;
readonly env: Record<string, string | undefined>;
readonly skillIds: readonly string[];
readonly skillPluginRoot: string;
readonly sandbox: AgentSandboxPolicy;
}
@@ -79,6 +82,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
assertShortAgentTemp(agentTmp);
const skillPlugin = await validateCuratedSkillPlugin();
const path = hostEnv["PATH"]?.trim();
if (path === undefined || path === "") {
@@ -119,6 +123,8 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
cwd: workspaceDir,
workspaceRoot,
env,
skillIds: skillPlugin.skillIds,
skillPluginRoot: skillPlugin.root,
sandbox: {
enabled: true,
failIfUnavailable: true,
@@ -134,7 +140,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
// workspace plus the named system runtime needed to execute tools.
// SDK allowRead takes precedence over matching denyRead paths.
denyRead: ["/"],
allowRead: [workspaceDir, ...runtimeReadPaths],
allowRead: [workspaceDir, skillPlugin.root, ...runtimeReadPaths],
},
credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
+7 -1
View File
@@ -37,6 +37,7 @@ import { createPermissionAuthorizer, type AuthorizationDecision, type Permission
import { writeAudit } from "../audit.js";
import { formatRunCostLine } from "../agent/cost.js";
import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { CURATED_SKILL_IDS } from "../agent/curatedSkills.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
@@ -405,6 +406,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
metadata: {
roleId,
model,
requestedSkills: [...CURATED_SKILL_IDS],
prompt: agentPrompt.slice(0, 200),
sender: senderMetadata,
feishuTriggerContext,
@@ -568,7 +570,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
runId: run.id,
projectId,
action: "run.finished",
metadata: { status: result.status, deliveredFiles },
metadata: {
status: result.status,
deliveredFiles,
initializedSkills: [...(result.initializedSkillIds ?? [])],
},
});
await removeProcessingReaction();
})