diff --git a/apps/mcp-server/src/rules/skill.schema.spec.ts b/apps/mcp-server/src/rules/skill.schema.spec.ts index a4702d7a..70dbda90 100644 --- a/apps/mcp-server/src/rules/skill.schema.spec.ts +++ b/apps/mcp-server/src/rules/skill.schema.spec.ts @@ -108,7 +108,7 @@ Content. expect(result.agent).toBe('security-specialist'); }); - it('should parse allowed-tools field', () => { + it('should parse allowed-tools field as array', () => { const content = `--- name: tools-skill description: Skill with allowed tools @@ -125,6 +125,48 @@ Content. expect(result.allowedTools).toEqual(['Read', 'Grep', 'Glob']); }); + it('should parse allowed-tools as comma-separated string', () => { + const content = `--- +name: tools-skill +description: Skill with comma-string tools +allowed-tools: Read, Grep, Glob +--- + +Content. +`; + const result = parseSkill(content, 'path'); + + expect(result.allowedTools).toEqual(['Read', 'Grep', 'Glob']); + }); + + it('should parse allowed-tools comma string with parenthesized patterns', () => { + const content = `--- +name: tools-skill +description: Skill with paren patterns +allowed-tools: "Read, Grep, Glob, Bash(gh:*, git:*)" +--- + +Content. +`; + const result = parseSkill(content, 'path'); + + expect(result.allowedTools).toEqual(['Read', 'Grep', 'Glob', 'Bash(gh:*, git:*)']); + }); + + it('should parse allowed-tools empty string as empty array', () => { + const content = `--- +name: tools-skill +description: Skill with empty tools +allowed-tools: "" +--- + +Content. +`; + const result = parseSkill(content, 'path'); + + expect(result.allowedTools).toEqual([]); + }); + it('should leave extended fields undefined when not present', () => { const content = `--- name: basic-skill diff --git a/apps/mcp-server/src/rules/skill.schema.ts b/apps/mcp-server/src/rules/skill.schema.ts index f11674c1..2f79c160 100644 --- a/apps/mcp-server/src/rules/skill.schema.ts +++ b/apps/mcp-server/src/rules/skill.schema.ts @@ -30,6 +30,31 @@ export class SkillSchemaError extends Error { // Zod Schemas // ============================================================================ +/** + * Split a comma-separated allowed-tools string, respecting parenthesized + * patterns like `Bash(gh:*, git:*)` where commas inside parens are not + * delimiters. + */ +function splitAllowedTools(s: string): string[] { + const result: string[] = []; + let current = ''; + let depth = 0; + for (const ch of s) { + if (ch === '(') depth++; + else if (ch === ')') depth--; + if (ch === ',' && depth === 0) { + const trimmed = current.trim(); + if (trimmed) result.push(trimmed); + current = ''; + } else { + current += ch; + } + } + const trimmed = current.trim(); + if (trimmed) result.push(trimmed); + return result; +} + /** * Trigger entry schema for SKILL.md frontmatter * - pattern: regex pattern string (non-empty) @@ -57,7 +82,9 @@ const SkillFrontmatterSchema = z.object({ 'disable-model-invocation': z.boolean().optional(), context: z.string().optional(), agent: z.string().optional(), - 'allowed-tools': z.array(z.string()).optional(), + 'allowed-tools': z + .union([z.array(z.string()), z.string().transform(s => splitAllowedTools(s))]) + .optional(), }); // ============================================================================