forked from bai/curriculum-project-hub
feat: add curated curriculum agent skills
This commit is contained in:
@@ -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
@@ -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) }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user