diff --git a/README.md b/README.md index 3338058..a813aac 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ validation path: 2. Parse YAML frontmatter with `gray-matter`. 3. Validate schema with `zod`. 4. Scan content against the denylist in `scripts/security/patterns.json`. -5. Cross-check declared capabilities against observed behavior. +5. Cross-check declared capabilities against observed behavior. Skills may include `metadata.author` and `metadata.source` (see the Agent Skills spec at agentskills.io). AutoVault-curated skills declare `metadata.author: "AutoVault"` (and `source`) so that hosts like Grok can group and attribute them (similar to how Resend skills appear under "Resend"). 6. Deduplicate exact, near-exact, and functionally similar proposals. 7. Write the skill, source sidecar, signed manifest, and Ed25519 signature. diff --git a/skills/autovault-skill/SKILL.md b/skills/autovault-skill/SKILL.md index 26eb5ba..caa778e 100644 --- a/skills/autovault-skill/SKILL.md +++ b/skills/autovault-skill/SKILL.md @@ -12,7 +12,9 @@ agents: - autojack category: meta metadata: + author: AutoVault version: "1.0.0" + source: https://github.com/autoworks-ai/autovault capabilities: network: false filesystem: readonly @@ -156,15 +158,20 @@ name: kebab-case-name description: At least 20 characters describing what the skill does and when to use it. agents: [claude-code, codex] metadata: + author: YourOrg version: "1.0.0" + source: https://github.com/yourorg/your-skill --- ``` Optional but recommended fields: `tags`, `category`, `license`, -`capabilities` (`network`, `filesystem`, `tools`), and -`requires-secrets`. If the bundle ships files beyond `SKILL.md`, declare them -in `resources:` with `type: file`, or let `propose_skill`/`bulk_import` infer -that list when `allow_synthesized_frontmatter` is not false. +`capabilities` (`network`, `filesystem`, `tools`), `requires-secrets`, and +`metadata` (with `author`, `source`, `version`). Use `metadata.author` (and +`metadata.source`) following the Agent Skills spec so host UIs (Grok, etc.) +can group AutoVault skills and show provenance. If the bundle ships files +beyond `SKILL.md`, declare them in `resources:` with `type: file`, or let +`propose_skill`/`bulk_import` infer that list when `allow_synthesized_frontmatter` +is not false. ## Security expectations diff --git a/skills/skill-author/SKILL.md b/skills/skill-author/SKILL.md index f7edfc3..45071c8 100644 --- a/skills/skill-author/SKILL.md +++ b/skills/skill-author/SKILL.md @@ -6,7 +6,9 @@ tags: [authoring, skills, autovault, meta, demo] agents: [claude-code, codex, autojack] category: meta metadata: + author: AutoVault version: "1.0.0" + source: https://github.com/autoworks-ai/autovault capabilities: network: false filesystem: readwrite @@ -35,6 +37,9 @@ skill already exists, reuse or extend it instead of creating a duplicate. name: kebab-case-name # letters, digits, hyphens, underscores description: At least 20 characters explaining WHAT the skill does and WHEN to use it. agents: [claude-code, codex] # at least one visible target profile +metadata: + author: YourOrg # for host UI grouping / attribution (Grok etc.) + source: https://github.com/yourorg/your-skill --- ``` @@ -45,6 +50,9 @@ agents: [claude-code, codex] # at least one visible target profile - `agents` is required. A skill with no target profile would enter the vault but be invisible to every generated skill directory, so AutoVault rejects it instead of accepting a hidden install. +- `metadata.author` and `metadata.source` (optional but recommended for + published/curated skills) surface in host UIs for attribution and grouping + (see agentskills.io spec and how Grok displays "(user ยท Resend)" skills). ## Recommended frontmatter @@ -53,7 +61,9 @@ license: MIT tags: [topic, tool, domain] category: metadata: + author: YourOrg version: "1.0.0" + source: https://github.com/yourorg/your-skill capabilities: network: false | true filesystem: readonly | readwrite @@ -185,7 +195,9 @@ license: MIT tags: [domain, tool] category: general metadata: + author: YourOrg version: "1.0.0" + source: https://github.com/yourorg/your-skill capabilities: network: false filesystem: readonly diff --git a/src/storage/index.ts b/src/storage/index.ts index 26c6f37..5f7ef76 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -323,11 +323,13 @@ function buildSummary( const capabilities = asCapabilities(frontmatter.capabilities); const requiresSecrets = asSecretsArray(frontmatter["requires-secrets"]); const frontmatterAgents = asStringArray(frontmatter.agents); + const author = Object.hasOwn(metadata, "author") && typeof metadata.author === "string" && metadata.author.length > 0 ? metadata.author : undefined; + const frontmatterSource = Object.hasOwn(metadata, "source") && typeof metadata.source === "string" && metadata.source.length > 0 ? metadata.source : undefined; return { name: asString(frontmatter.name, name), title: optionalString(frontmatter.title), description: asString(frontmatter.description, ""), - version: asString(metadata.version, "0.0.0"), + version: Object.hasOwn(metadata, "version") && typeof metadata.version === "string" && metadata.version.length > 0 ? metadata.version : "0.0.0", tags: asStringArray(frontmatter.tags), category: typeof frontmatter.category === "string" ? frontmatter.category : undefined, agents: frontmatterAgents.length > 0 ? frontmatterAgents : fallbackAgents, @@ -337,7 +339,9 @@ function buildSummary( capabilities, requires_tools: capabilities.tools, requires_secrets: requiresSecrets, - requiresSecrets + requiresSecrets, + author, + frontmatter_source: frontmatterSource }; } @@ -911,7 +915,9 @@ export async function readSkillSummary(name: string): Promise; - bin: Record; -}; +export type SkillRecord = SkillSummary & { + skillMd: string; + resources: Array<{ path: string; type: string }>; + bin: Record; +}; export type ValidationResult = { valid: boolean; diff --git a/src/validation/frontmatter.ts b/src/validation/frontmatter.ts index f54ff91..fe1bd09 100644 --- a/src/validation/frontmatter.ts +++ b/src/validation/frontmatter.ts @@ -36,3 +36,48 @@ function trimTrailingSpacesAndTabs(input: string): string { } return end === input.length ? input : input.slice(0, end); } + +/** + * Extract the metadata map (if present) from frontmatter data or raw SKILL.md. + * Avoids duplicating parse logic; callers can pass an already-parsed data record + * to skip re-parsing YAML. + */ +export function getMetadata( + input: string | Record +): Record { + let data: Record; + if (typeof input === "string") { + try { + const { data: parsed } = parseFrontmatter(input); + data = parsed; + } catch { + return Object.create(null); + } + } else { + data = input; + } + const rawMeta = (data as Record).metadata; + if (typeof rawMeta !== "object" || rawMeta === null || Array.isArray(rawMeta)) { + return Object.create(null); + } + // Defend against prototype pollution (repo already forbids __proto__ etc in other paths). + // Copy only own enumerable properties into a null-prototype object. + const safe: Record = Object.create(null); + for (const key of Object.keys(rawMeta)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") continue; + safe[key] = (rawMeta as Record)[key]; + } + return safe; +} + +export function extractAuthor(input: string | Record): string | undefined { + const meta = getMetadata(input); + const value = meta.author; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function extractSource(input: string | Record): string | undefined { + const meta = getMetadata(input); + const value = meta.source; + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/src/validation/schema.ts b/src/validation/schema.ts index b78405f..b420204 100644 --- a/src/validation/schema.ts +++ b/src/validation/schema.ts @@ -41,15 +41,15 @@ const NO_CONTROL_CHARS = /^[^\x00-\x1F\x7F]*$/; // lowercase alphanumeric + hyphen, must start with a letter. No `.`, no `/`, // no `\`, no `..`. The schema gate is layer one; syncProfiles also runs a // path-resolve check as defense-in-depth. -export const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; -const agentNameSchema = z - .string() - .min(1) - .regex(AGENT_NAME_PATTERN, "agent name must match ^[a-z][a-z0-9-]*$"); -const agentsSchema = z.preprocess( - (value) => (value === undefined ? [] : value), - z.array(agentNameSchema).min(1, "at least one agent is required") -); +export const AGENT_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; +const agentNameSchema = z + .string() + .min(1) + .regex(AGENT_NAME_PATTERN, "agent name must match ^[a-z][a-z0-9-]*$"); +const agentsSchema = z.preprocess( + (value) => (value === undefined ? [] : value), + z.array(agentNameSchema).min(1, "at least one agent is required") +); const binActionSchema = z.object({ command: z @@ -85,29 +85,30 @@ const binSchema = z }) .optional(); -const schema = z.object({ - name: z - .string() - .min(1) - .regex(/^[a-z0-9][a-z0-9-_]*$/i, "must be alphanumeric with - or _"), - title: z.string().min(1).optional(), - description: z.string().min(20), - license: z.string().optional(), - tags: z.array(z.string()).optional(), - agents: agentsSchema, - category: z.string().optional(), - when_to_use: z.string().min(1).optional(), - when_not_to_use: z.string().min(1).optional(), - risk_level: z.string().min(1).optional(), - metadata: z +const schema = z.object({ + name: z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9-_]*$/i, "must be alphanumeric with - or _"), + title: z.string().min(1).optional(), + description: z.string().min(20), + license: z.string().optional(), + tags: z.array(z.string()).optional(), + agents: agentsSchema, + category: z.string().optional(), + when_to_use: z.string().min(1).optional(), + when_not_to_use: z.string().min(1).optional(), + risk_level: z.string().min(1).optional(), + metadata: z .object({ version: z.string().default("1.0.0") }) + .passthrough() .optional(), capabilities: capabilitiesSchema, resources: resourceSchema, bin: binSchema, - "requires-secrets": z + "requires-secrets": z .array( z.object({ name: z.string().min(1), @@ -115,36 +116,36 @@ const schema = z.object({ required: z.boolean().optional() }) ) - .optional() -}); - -const schemaAllowingMissingAgents = schema.extend({ - agents: z.array(agentNameSchema).optional() -}); - -function hasOwn(data: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(data, key); -} - -export function validateAgentName(agent: string): boolean { - return AGENT_NAME_PATTERN.test(agent); -} - -export function validateSchema( - data: Record, - options: { allowMissingAgents?: boolean } = {} -): { - valid: boolean; - errors: string[]; -} { - const activeSchema = - options.allowMissingAgents === true && !hasOwn(data, "agents") - ? schemaAllowingMissingAgents - : schema; - const result = activeSchema.safeParse(data); - if (result.success) { - return { valid: true, errors: [] }; - } + .optional() +}); + +const schemaAllowingMissingAgents = schema.extend({ + agents: z.array(agentNameSchema).optional() +}); + +function hasOwn(data: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(data, key); +} + +export function validateAgentName(agent: string): boolean { + return AGENT_NAME_PATTERN.test(agent); +} + +export function validateSchema( + data: Record, + options: { allowMissingAgents?: boolean } = {} +): { + valid: boolean; + errors: string[]; +} { + const activeSchema = + options.allowMissingAgents === true && !hasOwn(data, "agents") + ? schemaAllowingMissingAgents + : schema; + const result = activeSchema.safeParse(data); + if (result.success) { + return { valid: true, errors: [] }; + } return { valid: false, errors: result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`) diff --git a/tests/list-search-get.test.ts b/tests/list-search-get.test.ts index bee222a..3fc136e 100644 --- a/tests/list-search-get.test.ts +++ b/tests/list-search-get.test.ts @@ -4,175 +4,202 @@ import { searchSkills } from "../src/tools/search-skills.js"; import { getSkill } from "../src/tools/get-skill.js"; import { writeSkill } from "../src/storage/index.js"; -const md = (name: string, extra = "") => `--- -name: ${name} -description: A description that is intentionally long enough to satisfy the schema length checks for ${name}. -${extra.includes("tags:") ? "" : `tags: - - alpha -`} -${extra}capabilities: - network: false - filesystem: readonly - tools: [Bash] -requires-secrets: - - name: EXAMPLE_TOKEN - description: Example token for test coverage - required: false -metadata: - version: "0.0.1" ---- - -# Body -`; - -describe("list/search/get tools", () => { +const md = (name: string, extra = "") => `--- +name: ${name} +description: A description that is intentionally long enough to satisfy the schema length checks for ${name}. +${extra.includes("tags:") ? "" : `tags: + - alpha +`} +${extra}capabilities: + network: false + filesystem: readonly + tools: [Bash] +requires-secrets: + - name: EXAMPLE_TOKEN + description: Example token for test coverage + required: false +metadata: + author: TestAuthor + version: "0.0.1" + source: https://example.com/test +--- + +# Body +`; + +describe("list/search/get tools", () => { it("listSkills returns parsed metadata from frontmatter", async () => { await writeSkill("alpha-skill", md("alpha-skill")); await writeSkill("beta-skill", md("beta-skill")); const result = await listSkills(); const names = result.skills.map((s) => s.name).sort(); expect(names).toEqual(["alpha-skill", "beta-skill"]); - for (const skill of result.skills) { - expect(skill.description).toMatch(/long enough/); - expect(skill.tags).toEqual(["alpha"]); - expect(skill.version).toBe("0.0.1"); - expect(skill.requires_tools).toEqual(["Bash"]); - expect(skill.requires_secrets[0]?.name).toBe("EXAMPLE_TOKEN"); - expect(skill.capabilities.filesystem).toBe("readonly"); - } - }); - - it("listSkills includes optional discovery metadata when present", async () => { - await writeSkill("metadata-rich", md("metadata-rich", `title: Metadata Rich Skill -category: discovery -when_to_use: Use when searching for skills by intent before loading full instructions. -when_not_to_use: Do not use when exact skill names are already known. -risk_level: low -`)); - const result = await listSkills(); - expect(result.skills[0]).toMatchObject({ - name: "metadata-rich", - title: "Metadata Rich Skill", - category: "discovery", - when_to_use: "Use when searching for skills by intent before loading full instructions.", - when_not_to_use: "Do not use when exact skill names are already known.", - risk_level: "low" - }); - }); - - it("searchSkills ranks by query against parsed metadata", async () => { - await writeSkill("alpha-skill", md("alpha-skill")); - await writeSkill("beta-skill", md("beta-skill")); - const result = await searchSkills("alpha"); - expect(result.matches[0]?.name).toBe("alpha-skill"); - expect(result.matches[0]?.reason).toMatch(/matched/); - expect(result.matches[0]?.search_type).toBe("metadata_text"); - expect(result.matches[0]?.reasons.map((reason) => reason.kind)).toContain("name_match"); - }); - - it("searchSkills explains tag and description metadata matches", async () => { - await writeSkill("cloudflare-worker", md("cloudflare-worker", `tags: - - cloudflare - - d1 -category: deployment -when_to_use: Use when deploying a Worker backed by D1 storage. -`)); - const result = await searchSkills("deploy worker with D1"); - expect(result.matches[0]?.name).toBe("cloudflare-worker"); - expect(result.matches[0]?.reasons.map((reason) => reason.kind)).toEqual( - expect.arrayContaining(["tag_match", "description_match"]) - ); - expect(result.matches[0]?.reason).toContain("matched tags"); - }); - - it("searchSkills ignores empty tags when explaining matches", async () => { - await writeSkill("empty-tag-skill", `--- -name: empty-tag-skill -description: A description that is intentionally long enough to satisfy the schema length checks. -tags: - - "" -metadata: - version: "0.0.1" ---- - -# Body -`); - const result = await searchSkills("empty"); - expect(result.matches[0]?.name).toBe("empty-tag-skill"); - expect(result.matches[0]?.reasons.map((reason) => reason.kind)).not.toContain("tag_match"); - }); - - it("searchSkills returns empty matches for unrelated queries", async () => { + for (const skill of result.skills) { + expect(skill.description).toMatch(/long enough/); + expect(skill.tags).toEqual(["alpha"]); + expect(skill.version).toBe("0.0.1"); + expect(skill.author).toBe("TestAuthor"); + expect(skill.frontmatter_source).toBe("https://example.com/test"); + expect(skill.requires_tools).toEqual(["Bash"]); + expect(skill.requires_secrets[0]?.name).toBe("EXAMPLE_TOKEN"); + expect(skill.capabilities.filesystem).toBe("readonly"); + } + }); + + it("listSkills includes optional discovery metadata when present", async () => { + await writeSkill("metadata-rich", md("metadata-rich", `title: Metadata Rich Skill +category: discovery +when_to_use: Use when searching for skills by intent before loading full instructions. +when_not_to_use: Do not use when exact skill names are already known. +risk_level: low +`)); + const result = await listSkills(); + expect(result.skills[0]).toMatchObject({ + name: "metadata-rich", + title: "Metadata Rich Skill", + category: "discovery", + when_to_use: "Use when searching for skills by intent before loading full instructions.", + when_not_to_use: "Do not use when exact skill names are already known.", + risk_level: "low", + author: "TestAuthor", + frontmatter_source: "https://example.com/test" + }); + }); + + it("searchSkills ranks by query against parsed metadata", async () => { + await writeSkill("alpha-skill", md("alpha-skill")); + await writeSkill("beta-skill", md("beta-skill")); + const result = await searchSkills("alpha"); + expect(result.matches[0]?.name).toBe("alpha-skill"); + expect(result.matches[0]?.reason).toMatch(/matched/); + expect(result.matches[0]?.search_type).toBe("metadata_text"); + expect(result.matches[0]?.reasons.map((reason) => reason.kind)).toContain("name_match"); + }); + + it("searchSkills explains tag and description metadata matches", async () => { + await writeSkill("cloudflare-worker", md("cloudflare-worker", `tags: + - cloudflare + - d1 +category: deployment +when_to_use: Use when deploying a Worker backed by D1 storage. +`)); + const result = await searchSkills("deploy worker with D1"); + expect(result.matches[0]?.name).toBe("cloudflare-worker"); + expect(result.matches[0]?.reasons.map((reason) => reason.kind)).toEqual( + expect.arrayContaining(["tag_match", "description_match"]) + ); + expect(result.matches[0]?.reason).toContain("matched tags"); + }); + + it("searchSkills ignores empty tags when explaining matches", async () => { + await writeSkill("empty-tag-skill", `--- +name: empty-tag-skill +description: A description that is intentionally long enough to satisfy the schema length checks. +tags: + - "" +metadata: + version: "0.0.1" +--- + +# Body +`); + const result = await searchSkills("empty"); + expect(result.matches[0]?.name).toBe("empty-tag-skill"); + expect(result.matches[0]?.reasons.map((reason) => reason.kind)).not.toContain("tag_match"); + }); + + it("searchSkills returns empty matches for unrelated queries", async () => { const result = await searchSkills("totallyunrelatedquery"); expect(result.matches).toHaveLength(0); }); - it("getSkill returns the full record plus source metadata when available", async () => { - await writeSkill("alpha-skill", md("alpha-skill"), [], { - source: "github", - identifier: "owner/repo", - fetchedAt: new Date().toISOString(), + it("getSkill returns the full record plus source metadata when available", async () => { + await writeSkill("alpha-skill", `--- +name: alpha-skill +description: A description that is intentionally long enough to satisfy the schema length checks for alpha-skill. +tags: + - alpha +metadata: + author: TestAuthor + version: "0.0.0" + source: https://example.com/test +capabilities: + network: false + filesystem: readonly + tools: [Bash] +requires-secrets: + - name: EXAMPLE_TOKEN + description: Example token for test coverage + required: false +--- +# Body +`, [], { + source: "github", + identifier: "owner/repo", + fetchedAt: new Date().toISOString(), contentHash: "deadbeef" }); const skill = await getSkill("alpha-skill"); - expect(skill.name).toBe("alpha-skill"); - expect(skill.skill_md).toMatch(/Body/); - expect((skill.source as { source: string }).source).toBe("github"); - }); - - it("getSkill can inline packaged resources when requested", async () => { - await writeSkill("resource-skill", `--- -name: resource-skill -description: A description that is intentionally long enough to satisfy the schema length checks for resource-skill. -metadata: - version: "0.0.1" -resources: - - path: references/guide.md ---- - -# Body -`, [ - { path: "references/guide.md", content: "# guide\n" } - ]); - const skill = await getSkill("resource-skill", undefined, { includeResources: true }); - expect(skill.resource_contents).toEqual([ - { - path: "references/guide.md", - content: "# guide\n", - mime_type: "text/markdown" - } - ]); - }); - - it("getSkill throws when the skill does not exist", async () => { - await expect(getSkill("missing-skill")).rejects.toThrow(/not found/); - }); - - it("gold discovery queries find expected skill fixtures", async () => { - await writeSkill("parallel-task-batch", md("parallel-task-batch", `tags: - - parallel - - pull-request -category: orchestration -when_to_use: Use to run several agents in parallel and merge their PRs after review. -`)); - await writeSkill("copilot-review", md("copilot-review", `tags: - - copilot - - pull-request -category: review -when_to_use: Use to fix Copilot comments on a PR and resolve review threads. -`)); - await writeSkill("cloudflare-ops", md("cloudflare-ops", `tags: - - cloudflare - - d1 - - worker -category: deployment -when_to_use: Use to deploy a Worker with D1 storage on Cloudflare. -`)); - - expect((await searchSkills("run several agents in parallel and merge their PRs")).matches[0]?.name).toBe( - "parallel-task-batch" - ); - expect((await searchSkills("fix Copilot comments on a PR")).matches[0]?.name).toBe("copilot-review"); - expect((await searchSkills("deploy a worker with D1")).matches[0]?.name).toBe("cloudflare-ops"); - }); -}); + expect(skill.name).toBe("alpha-skill"); + expect(skill.skill_md).toMatch(/Body/); + expect((skill.source as { source: string }).source).toBe("github"); + expect(skill.author).toBe("TestAuthor"); + expect(skill.frontmatter_source).toBe("https://example.com/test"); + }); + + it("getSkill can inline packaged resources when requested", async () => { + await writeSkill("resource-skill", `--- +name: resource-skill +description: A description that is intentionally long enough to satisfy the schema length checks for resource-skill. +metadata: + version: "0.0.1" +resources: + - path: references/guide.md +--- + +# Body +`, [ + { path: "references/guide.md", content: "# guide\n" } + ]); + const skill = await getSkill("resource-skill", undefined, { includeResources: true }); + expect(skill.resource_contents).toEqual([ + { + path: "references/guide.md", + content: "# guide\n", + mime_type: "text/markdown" + } + ]); + }); + + it("getSkill throws when the skill does not exist", async () => { + await expect(getSkill("missing-skill")).rejects.toThrow(/not found/); + }); + + it("gold discovery queries find expected skill fixtures", async () => { + await writeSkill("parallel-task-batch", md("parallel-task-batch", `tags: + - parallel + - pull-request +category: orchestration +when_to_use: Use to run several agents in parallel and merge their PRs after review. +`)); + await writeSkill("copilot-review", md("copilot-review", `tags: + - copilot + - pull-request +category: review +when_to_use: Use to fix Copilot comments on a PR and resolve review threads. +`)); + await writeSkill("cloudflare-ops", md("cloudflare-ops", `tags: + - cloudflare + - d1 + - worker +category: deployment +when_to_use: Use to deploy a Worker with D1 storage on Cloudflare. +`)); + + expect((await searchSkills("run several agents in parallel and merge their PRs")).matches[0]?.name).toBe( + "parallel-task-batch" + ); + expect((await searchSkills("fix Copilot comments on a PR")).matches[0]?.name).toBe("copilot-review"); + expect((await searchSkills("deploy a worker with D1")).matches[0]?.name).toBe("cloudflare-ops"); + }); +});