diff --git a/skills/capabilities-manager/SKILL.md b/skills/capabilities-manager/SKILL.md index fb59f4e..d614bb4 100644 --- a/skills/capabilities-manager/SKILL.md +++ b/skills/capabilities-manager/SKILL.md @@ -1,6 +1,6 @@ --- name: capabilities-manager -description: Guide for managing capabilities, skills, tools, and MCP servers with capa. Use this skill when you need to modify the capabilities.yaml or capabilities.json file. +description: Guide for managing capabilities, skills, tools, and MCP servers with capa. Use this skill when you need to modify the capabilities.yaml or capabilities.json file. Includes security options (blocked phrases, character sanitization). --- # Capabilities Manager @@ -18,6 +18,7 @@ Use this skill when: - User wants to find available skills from the skills.sh ecosystem - User needs to manage the CAPA server (start/stop/restart/status) - User wants to understand tool exposure modes (on-demand vs expose-all) +- User needs to configure security (blocked phrases, character sanitization) ## Core Concepts @@ -25,7 +26,7 @@ Use this skill when: The `capabilities.yaml` (or `capabilities.json`) file defines everything an agent can do. It contains five main sections: 1. **providers**: List of MCP clients where skills should be installed (e.g., `cursor`, `claude-code`) -2. **options**: Configuration for tool exposure behavior (`toolExposure`: `expose-all` or `on-demand`) +2. **options**: Configuration for tool exposure (`toolExposure`) and security (`security`) 3. **skills**: Modular knowledge packages that teach agents when and how to use tools 4. **servers**: MCP servers that provide tools (local subprocesses or remote HTTP servers) 5. **tools**: Executable capabilities (MCP tools or shell commands) @@ -38,6 +39,15 @@ The `capabilities.yaml` (or `capabilities.json`) file defines everything an agen - **expose-all** (default): All tools from all skills are exposed immediately when the MCP client connects - **on-demand**: Tools are only exposed after the agent calls `setup_tools(["skill-id"])`, keeping the initial context clean +### Security Options +Under `options.security` you can configure: +- **blockedPhrases**: Block skill installation if any skill file contains these phrases. Configure inline as a list or via a `.txt` file reference. Omit or comment out to disable. +- **allowedCharacters**: Additional regex character class for characters to allow **beyond** the always-preserved baseline. The baseline (tab, LF, CR, all printable ASCII U+0020–U+007E) is hardcoded and never stripped, so `-`, `:`, `"`, `'`, newlines, and all keyboard symbols are always safe. Use this to permit extra Unicode ranges (e.g. `[\\u00A0-\\uFFFF]` for all Unicode, including emoji). Set to an empty string to apply baseline-only sanitization. Omit or comment out to disable entirely. + +Both features can be disabled independently by removing or commenting out each property. Omit the `security` block entirely to disable both. Only properties that are present are applied. + +When a blocked phrase is detected during `capa install`, the installation stops immediately and displays which skill (or plugin skill) contains the phrase and what the phrase is. No skills are installed until the issue is resolved. + ## Available Commands ### Initialize Capabilities @@ -61,6 +71,8 @@ Reads the capabilities file and: 3. Prompts for any required credentials via web UI (unless `-e` flag is used) 4. Registers the project's MCP endpoint in client config files +**Security**: If `options.security` is configured with blockedPhrases or allowedCharacters, the corresponding checks run during installation. Omit or comment out each property to disable it. If a blocked phrase is found, installation stops immediately and reports which skill and phrase caused the block. When allowedCharacters is present, character sanitization runs: the baseline (printable ASCII + standard whitespace) is always preserved, and the value specifies extra Unicode ranges to keep on top of that. + **Flags**: - `-e, --env [file]`: Load variables from a `.env` file instead of using the web UI - Without filename: Uses `.env` in the project directory @@ -130,6 +142,11 @@ providers: options: toolExposure: expose-all # or 'on-demand' + # Optional security (blocked phrases, character sanitization) + # security: + # blockedPhrases: [] + # # Or load from file: blockedPhrases: { file: "./blocked-phrases.txt" } + # allowedCharacters: "" # "" = baseline only (strips non-ASCII); "[\\u00A0-\\uFFFF]" = allow all Unicode skills: - id: skill-id @@ -279,6 +296,53 @@ servers: **Variable Substitution**: Use `${VarName}` for credentials. CAPA will prompt for these securely via a web UI. +### Security Options + +Under `options.security`, you can enforce safety during skill installation: + +#### Blocked Phrases +Block installation if any skill file (SKILL.md or additional files) contains a forbidden phrase. Omit or comment out to disable. Configure either inline or via file: + +**Inline phrases:** +```yaml +options: + security: + blockedPhrases: + - "some-dangerous-command" +``` + +**Phrases from file (one phrase per line):** +```yaml +options: + security: + blockedPhrases: + file: "./blocked-phrases.txt" +``` + +The file path is relative to the capabilities file directory. Empty lines are ignored. + +#### Character Sanitization +Replace disallowed characters with spaces during installation. Omit or comment out to disable. Useful to restrict skills to safe character sets. + +```yaml +options: + security: + allowedCharacters: "" # baseline only: strips non-ASCII Unicode (emoji, etc.) + # allowedCharacters: "[\\u00A0-\\uFFFF]" # allow all printable Unicode including emoji +``` + +**How it works:** A hardcoded baseline—tab, LF, CR, and all printable ASCII (U+0020–U+007E)—is **always preserved** no matter what. Characters like `-`, `:`, `"`, `'`, `\n`, and every keyboard symbol are in the baseline and will never be stripped. The `allowedCharacters` field extends the baseline by specifying **additional** Unicode ranges to keep. Characters outside both the baseline and the extra allowance are replaced with a space. + +Only text files (`.md`, `.txt`, `.ts`, `.js`, `.json`, `.yaml`, etc.) are sanitized; other files are copied as-is. Omit or comment out `allowedCharacters` to disable sanitization entirely. + +#### Blocked Phrase Detection +When `capa install` detects a blocked phrase, it **stops immediately** and reports: +- Which skill (or skill in plugin) contains it +- The file path +- The forbidden phrase + +No further skills are installed until you remove the phrase from the skill or update your security configuration. + ### Tools Section Define tools that skills can use: @@ -735,6 +799,12 @@ cat .cursor/mcp.json - Test server command manually outside CAPA - Check if port 5912 is available +### Installation Blocked: Forbidden Phrase Detected +When you see a red "Installation blocked" message during `capa install`: +- A skill (or skill in a plugin) contains a phrase from your `options.security.blockedPhrases` list +- The message shows the skill ID, file path, and the forbidden phrase +- **Resolution**: Remove the phrase from the skill's files, or remove/comment out blockedPhrases (or change the restriction) in your capabilities file, then run capa install again + ### Tool Not Found Errors - Verify tool ID matches between skill `requires` and tools section - Check that server ID in tool definition uses `@` prefix (e.g., `@server-id`) diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index 7a9b5d5..298ebd6 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -18,6 +18,17 @@ import { registerMCPServer } from '../utils/mcp-client-manager'; import { parseEnvFile } from '../../shared/env-parser'; import { extractAllVariables } from '../../shared/variable-resolver'; import { resolvePlugins } from './plugin-install'; +import { + loadBlockedPhrases, + checkBlockedPhrases, + sanitizeContent, + getAllowedCharacters, + isTextFile, + isBlockedPhrasesEnabled, + isCharacterSanitizationEnabled, + BlockedPhraseError, + reportBlockedPhraseAndExit, +} from '../../shared/skill-security'; const execAsync = promisify(exec); @@ -373,7 +384,8 @@ export async function installCommand(envFile?: string | boolean): Promise authFetch, db, (platform, repoPath, auth, version?, ref?) => - cloneRepository(platform, repoPath, auth, version, ref) + cloneRepository(platform, repoPath, auth, version, ref), + capabilitiesFile.path ); capabilitiesToUse = mergedCapabilities; for (const dir of tempDirsToCleanup) { @@ -382,6 +394,15 @@ export async function installCommand(envFile?: string | boolean): Promise } catch {} } } catch (err: any) { + if (err instanceof BlockedPhraseError) { + db.close(); + reportBlockedPhraseAndExit( + err.skillId, + err.filePath, + err.phrase, + err.pluginName + ); + } console.error(`✗ Plugin resolution failed: ${err.message}`); db.close(); process.exit(1); @@ -464,7 +485,16 @@ export async function installCommand(envFile?: string | boolean): Promise // Step 2: Install skills (copy to client directories) — only base skills from file; plugin skills already installed in resolvePlugins console.log('\n📦 Installing skills...'); - await installSkills(projectPath, projectId, capabilities.skills, capabilitiesToUse.providers, db, settings); + await installSkills( + projectPath, + projectId, + capabilities.skills, + capabilitiesToUse.providers, + db, + settings, + capabilitiesToUse, + capabilitiesFile.path + ); // Step 3: Submit capabilities to server (merged, including plugin-derived) console.log('\n🔧 Configuring tools...'); @@ -651,7 +681,9 @@ async function installSkills( skills: Skill[], clients: string[], db: CapaDatabase, - settings: any + settings: any, + capabilities: Capabilities, + capabilitiesFilePath: string ): Promise { const authFetch = createAuthenticatedFetch(db); @@ -891,7 +923,48 @@ async function installSkills( continue; } - + + // Security: blocked phrases and character sanitization (each can be disabled independently) + const security = capabilities.options?.security; + const blockPhrasesEnabled = isBlockedPhrasesEnabled(security); + const sanitizeEnabled = isCharacterSanitizationEnabled(security); + + if (blockPhrasesEnabled) { + let blockedPhrases: string[]; + try { + blockedPhrases = loadBlockedPhrases(security, capabilitiesFilePath); + } catch (err: any) { + console.error(` ✗ Failed to load blocked phrases for skill ${skill.id}: ${err.message}`); + continue; + } + const mdCheck = checkBlockedPhrases(skillMarkdown, blockedPhrases); + if (mdCheck.blocked) { + reportBlockedPhraseAndExit(skill.id, 'SKILL.md', mdCheck.phrase!); + } + for (const [filename, content] of additionalFiles) { + if (!isTextFile(filename)) continue; + const check = checkBlockedPhrases(content, blockedPhrases); + if (check.blocked) { + reportBlockedPhraseAndExit(skill.id, filename, check.phrase!); + } + } + } + + if (sanitizeEnabled) { + const allowedCharacters = getAllowedCharacters(security); + if (allowedCharacters !== null) { + skillMarkdown = sanitizeContent(skillMarkdown, allowedCharacters); + const sanitizedAdditional = new Map(); + for (const [filename, content] of additionalFiles) { + sanitizedAdditional.set( + filename, + isTextFile(filename) ? sanitizeContent(content, allowedCharacters) : content + ); + } + additionalFiles = sanitizedAdditional; + } + } + // Install skill for each client for (const client of clients) { // Get the agent configuration from the skills package diff --git a/src/cli/commands/plugin-install.ts b/src/cli/commands/plugin-install.ts index b43dc0c..1c6b3f1 100644 --- a/src/cli/commands/plugin-install.ts +++ b/src/cli/commands/plugin-install.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, cpSync } from 'fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, cpSync, statSync } from 'fs'; import { tmpdir } from 'os'; import { join, resolve } from 'path'; import type { Capabilities, Skill, MCPServer, SourcePlugin, ResolvedPluginInfo } from '../../types/capabilities'; @@ -8,6 +8,16 @@ import { parsePluginUri, getRepoPath, getPluginInstallId } from '../../shared/pl import { detectAndParseManifest, resolvePluginServerDef } from '../../shared/plugin-manifest'; import { getAgentConfig } from 'skills/src/agents'; import type { AgentType } from 'skills/src/types'; +import { + loadBlockedPhrases, + checkBlockedPhrases, + sanitizeContent, + getAllowedCharacters, + isTextFile, + isBlockedPhrasesEnabled, + isCharacterSanitizationEnabled, + BlockedPhraseError, +} from '../../shared/skill-security'; /** Base under system temp for extracted plugin content (MCP cwd). Per-project so projects don't clash. */ function getPluginsTempBase(projectId: string): string { @@ -41,6 +51,68 @@ function copyPluginToStable(tempDir: string, pluginStablePath: string): void { } } +/** + * Copy a skill directory with security checks: blocked phrases and character sanitization. + * Throws BlockedPhraseError if any text file contains a blocked phrase. + * @param allowedCharacters - null to skip sanitization + */ +function copySkillDirWithSecurity( + srcSkillDir: string, + destSkillDir: string, + skillId: string, + blockedPhrases: string[], + allowedCharacters: string | null, + pluginName?: string +): void { + mkdirSync(destSkillDir, { recursive: true }); + + function processEntry(relPath: string): void { + const srcPath = join(srcSkillDir, relPath); + const destPath = join(destSkillDir, relPath); + const stat = statSync(srcPath); + + if (stat.isDirectory()) { + mkdirSync(destPath, { recursive: true }); + for (const e of readdirSync(srcPath, { withFileTypes: true })) { + processEntry(join(relPath, e.name).replace(/\\/g, '/')); + } + } else { + mkdirSync(resolve(destPath, '..'), { recursive: true }); + const filename = relPath.split(/[/\\]/).pop() ?? ''; + + if (isTextFile(filename)) { + let content: string; + try { + content = readFileSync(srcPath, 'utf-8'); + } catch { + writeFileSync(destPath, readFileSync(srcPath)); + return; + } + const check = checkBlockedPhrases(content, blockedPhrases); + if (check.blocked) { + throw new BlockedPhraseError( + `Skill "${skillId}" blocked: file "${relPath}" contains forbidden phrase "${check.phrase}"`, + skillId, + relPath, + check.phrase!, + pluginName + ); + } + const output = allowedCharacters !== null + ? sanitizeContent(content, allowedCharacters) + : content; + writeFileSync(destPath, output, 'utf-8'); + } else { + writeFileSync(destPath, readFileSync(srcPath)); + } + } + } + + for (const e of readdirSync(srcSkillDir, { withFileTypes: true })) { + processEntry(e.name); + } +} + export interface ResolvePluginsResult { mergedCapabilities: Capabilities; tempDirsToCleanup: string[]; @@ -49,6 +121,7 @@ export interface ResolvePluginsResult { /** * Resolve all plugins from capabilities: clone, unpack, parse manifest, install skills and build merged capabilities. * Caller is responsible for cleaning up tempDirsToCleanup. + * @param capabilitiesFilePath - Path to capabilities file (for resolving blocked phrases file) */ export async function resolvePlugins( capabilities: Capabilities, @@ -62,7 +135,8 @@ export async function resolvePlugins( authFetch: AuthenticatedFetch, version?: string, ref?: string - ) => Promise + ) => Promise, + capabilitiesFilePath: string ): Promise { const plugins = capabilities.plugins ?? []; const mergedSkills: Skill[] = Array.isArray(capabilities.skills) ? [...capabilities.skills] : []; @@ -142,6 +216,13 @@ export async function resolvePlugins( repository, }); + const security = capabilities.options?.security; + const blockPhrasesEnabled = isBlockedPhrasesEnabled(security); + const sanitizeEnabled = isCharacterSanitizationEnabled(security); + const hasSecurity = blockPhrasesEnabled || sanitizeEnabled; + const blockedPhrases = blockPhrasesEnabled ? loadBlockedPhrases(security, capabilitiesFilePath) : []; + const allowedCharacters = sanitizeEnabled ? getAllowedCharacters(security) : null; + for (const entry of manifest.skillEntries) { const srcSkillDir = join(pluginStablePath, entry.relativePath); if (!existsSync(join(srcSkillDir, 'SKILL.md'))) continue; @@ -154,13 +235,27 @@ export async function resolvePlugins( try { if (existsSync(destSkillDir)) rmSync(destSkillDir, { recursive: true, force: true }); mkdirSync(resolve(destSkillDir, '..'), { recursive: true }); - try { - cpSync(srcSkillDir, destSkillDir, { recursive: true }); - } catch { - copyDirRecursive(srcSkillDir, destSkillDir); + if (hasSecurity) { + copySkillDirWithSecurity( + srcSkillDir, + destSkillDir, + entry.id, + blockedPhrases, + allowedCharacters, + manifest.name + ); + } else { + try { + cpSync(srcSkillDir, destSkillDir, { recursive: true }); + } catch { + copyDirRecursive(srcSkillDir, destSkillDir); + } } db.addManagedFile(projectId, destSkillDir); } catch (err: any) { + if (err instanceof BlockedPhraseError) { + throw err; + } console.warn(` ⚠ Failed to install skill ${entry.id} for ${client}: ${err.message}`); } } diff --git a/src/server/mcp-handler.ts b/src/server/mcp-handler.ts index b977f46..c6a7192 100644 --- a/src/server/mcp-handler.ts +++ b/src/server/mcp-handler.ts @@ -13,7 +13,6 @@ import { SessionManager } from './session-manager'; import { CommandToolExecutor } from './tool-executor'; import { MCPProxy } from './mcp-proxy'; import { SubprocessManager } from './subprocess-manager'; -import { extractAllVariables } from '../shared/variable-resolver'; import { VERSION } from '../version'; import { logger } from '../shared/logger'; @@ -93,7 +92,7 @@ export class CapaMCPServer { // On-demand mode: Only expose meta-tools (setup_tools and call_tool) tools.push({ name: 'setup_tools', - description: 'Activate skills and load their required tools. Returns the full list of available tools with their schemas for your reference.', + description: 'Activate skills and load their required tools. This tool should always be called when the agent learns (loads) a skill. Returns the full list of available tools with their schemas for your reference.', inputSchema: { type: 'object', properties: { @@ -743,7 +742,7 @@ export class CapaMCPServer { // On-demand mode: Only expose meta-tools (setup_tools and call_tool) tools.push({ name: 'setup_tools', - description: 'Activate skills and load their required tools. Returns the full list of available tools with their schemas for your reference.', + description: 'Activate skills and load their required tools. This tool should always be called when the agent learns (loads) a skill. Returns the full list of available tools with their schemas for your reference.', inputSchema: { type: 'object', properties: { diff --git a/src/shared/__tests__/capabilities.test.ts b/src/shared/__tests__/capabilities.test.ts index ec5f36b..9c40f0d 100644 --- a/src/shared/__tests__/capabilities.test.ts +++ b/src/shared/__tests__/capabilities.test.ts @@ -49,6 +49,14 @@ describe('capabilities', () => { expect(capabilities.tools.some(t => t.id === 'capa_install')).toBe(true); expect(capabilities.tools.some(t => t.id === 'find_skills')).toBe(true); }); + + it('should include security options template', () => { + const capabilities = createDefaultCapabilities(); + + expect(capabilities.options?.security).toBeDefined(); + expect(Array.isArray(capabilities.options?.security?.blockedPhrases)).toBe(true); + expect(capabilities.options?.security?.allowedCharacters).toBeDefined(); + }); }); describe('writeCapabilitiesFile and parseCapabilitiesFile', () => { diff --git a/src/shared/__tests__/skill-security.test.ts b/src/shared/__tests__/skill-security.test.ts new file mode 100644 index 0000000..5fcf32b --- /dev/null +++ b/src/shared/__tests__/skill-security.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { + loadBlockedPhrases, + checkBlockedPhrases, + sanitizeContent, + getAllowedCharacters, + isTextFile, + isBlockedPhrasesEnabled, + isCharacterSanitizationEnabled, +} from '../skill-security'; +import type { SecurityOptions } from '../../types/capabilities'; + +describe('skill-security', () => { + let tempDir: string; + let capabilitiesPath: string; + + beforeAll(() => { + tempDir = join(tmpdir(), `capa-skill-security-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + capabilitiesPath = join(tempDir, 'capabilities.json'); + writeFileSync(capabilitiesPath, '{}', 'utf-8'); + }); + + afterAll(() => { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('loadBlockedPhrases', () => { + it('should return empty array when no security config', () => { + expect(loadBlockedPhrases(undefined, capabilitiesPath)).toEqual([]); + }); + + it('should return empty array when blockedPhrases is omitted (disabled)', () => { + expect(loadBlockedPhrases({}, capabilitiesPath)).toEqual([]); + }); + + it('should return inline phrases when array provided', () => { + const security: SecurityOptions = { + blockedPhrases: ['eval(', 'exec(', 'danger'], + }; + expect(loadBlockedPhrases(security, capabilitiesPath)).toEqual([ + 'eval(', + 'exec(', + 'danger', + ]); + }); + + it('should filter empty strings from inline array', () => { + const security: SecurityOptions = { + blockedPhrases: ['a', '', 'b', ' ', 'c'], + }; + expect(loadBlockedPhrases(security, capabilitiesPath)).toEqual(['a', 'b', 'c']); + }); + + it('should load phrases from file when file reference provided', () => { + const phrasesPath = join(tempDir, 'blocked.txt'); + writeFileSync( + phrasesPath, + 'phrase1\nphrase2\n\nphrase3\n trimmed \n', + 'utf-8' + ); + const security: SecurityOptions = { + blockedPhrases: { file: 'blocked.txt' }, + }; + const result = loadBlockedPhrases(security, capabilitiesPath); + expect(result).toEqual(['phrase1', 'phrase2', 'phrase3', 'trimmed']); + }); + + it('should throw when blocked phrases file not found', () => { + const security: SecurityOptions = { + blockedPhrases: { file: 'nonexistent.txt' }, + }; + expect(() => loadBlockedPhrases(security, capabilitiesPath)).toThrow( + /Blocked phrases file not found/ + ); + }); + }); + + describe('checkBlockedPhrases', () => { + it('should return not blocked when no phrases', () => { + expect(checkBlockedPhrases('some content', [])).toEqual({ blocked: false }); + }); + + it('should return not blocked when phrase not in content', () => { + expect(checkBlockedPhrases('hello world', ['eval('])).toEqual({ blocked: false }); + }); + + it('should return blocked when phrase in content', () => { + expect(checkBlockedPhrases('use eval() here', ['eval('])).toEqual({ + blocked: true, + phrase: 'eval(', + }); + }); + + it('should be case-sensitive', () => { + expect(checkBlockedPhrases('EVAL()', ['eval('])).toEqual({ blocked: false }); + expect(checkBlockedPhrases('eval()', ['eval('])).toEqual({ + blocked: true, + phrase: 'eval(', + }); + }); + }); + + describe('sanitizeContent', () => { + it('should always preserve printable ASCII (baseline) regardless of user allow-list', () => { + // @ and # are in printable ASCII baseline — preserved even though not in [a-zA-Z0-9\s] + const result = sanitizeContent('hello@world#123', '[a-zA-Z0-9\\s]'); + expect(result).toBe('hello@world#123'); + }); + + it('should strip non-ASCII Unicode when not covered by user allow-list', () => { + // ✓ (U+2713) is outside the ASCII baseline; empty extra allow-list = baseline only + const result = sanitizeContent('ok\u2713fail', ''); + expect(result).toBe('ok fail'); + }); + + it('should strip control characters (below U+0020) that are not tab/LF/CR', () => { + // Null byte (U+0000) is not in baseline + const result = sanitizeContent('abc\u0000def', ''); + expect(result).toBe('abc def'); + }); + + it('should preserve baseline chars even with a restrictive allow-list', () => { + // Colon, dash, quote — markdown-critical chars — always preserved via baseline + const result = sanitizeContent('key: "value"\n- item', '[a-z]'); + expect(result).toBe('key: "value"\n- item'); + }); + + it('should preserve CR (Windows line endings) via baseline', () => { + const result = sanitizeContent('line1\r\nline2', ''); + expect(result).toBe('line1\r\nline2'); + }); + + it('should allow extended Unicode when specified by user', () => { + // ✓ is U+2713, within [\\u2600-\\u27FF] + const result = sanitizeContent('status \u2713 ok', '[\\u2600-\\u27FF]'); + expect(result).toBe('status \u2713 ok'); + }); + + it('should strip Unicode outside the combined baseline + user allow-list', () => { + // 📦 (U+1F4E6) is not in ASCII or [\\u2600-\\u27FF] + const result = sanitizeContent('box\uD83D\uDCE6end', '[\\u2600-\\u27FF]'); + expect(result).toBe('box end'); // surrogate pair = two replacements + }); + }); + + describe('getAllowedCharacters', () => { + it('should return null when security undefined (disabled)', () => { + expect(getAllowedCharacters(undefined)).toBeNull(); + }); + + it('should return custom when provided', () => { + const security: SecurityOptions = { + allowedCharacters: '[a-z]', + }; + expect(getAllowedCharacters(security)).toBe('[a-z]'); + }); + + it('should return null when omitted (disabled)', () => { + expect(getAllowedCharacters({})).toBeNull(); + }); + + it('should return empty string when explicitly set to empty (baseline-only sanitization)', () => { + expect(getAllowedCharacters({ allowedCharacters: '' })).toBe(''); + }); + + it('should return null when allowedCharacters is non-string', () => { + expect(getAllowedCharacters({ allowedCharacters: [] as any })).toBeNull(); + expect(getAllowedCharacters({ allowedCharacters: 123 as any })).toBeNull(); + }); + }); + + describe('isBlockedPhrasesEnabled', () => { + it('should return false when security undefined', () => { + expect(isBlockedPhrasesEnabled(undefined)).toBe(false); + }); + it('should return false when blockedPhrases is omitted', () => { + expect(isBlockedPhrasesEnabled({})).toBe(false); + }); + it('should return true when blockedPhrases is present', () => { + expect(isBlockedPhrasesEnabled({ blockedPhrases: ['a'] })).toBe(true); + }); + }); + + describe('isCharacterSanitizationEnabled', () => { + it('should return false when security undefined', () => { + expect(isCharacterSanitizationEnabled(undefined)).toBe(false); + }); + it('should return false when allowedCharacters is omitted', () => { + expect(isCharacterSanitizationEnabled({})).toBe(false); + }); + it('should return true when allowedCharacters is present', () => { + expect(isCharacterSanitizationEnabled({ allowedCharacters: '[a-z]' })).toBe(true); + }); + }); + + describe('isTextFile', () => { + it('should return true for text extensions', () => { + expect(isTextFile('file.md')).toBe(true); + expect(isTextFile('file.txt')).toBe(true); + expect(isTextFile('file.ts')).toBe(true); + expect(isTextFile('file.json')).toBe(true); + expect(isTextFile('SKILL.md')).toBe(true); + }); + + it('should return false for non-text extensions', () => { + expect(isTextFile('file.png')).toBe(false); + expect(isTextFile('file.exe')).toBe(false); + expect(isTextFile('file')).toBe(false); + }); + }); +}); diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index 259ba35..7deb684 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -19,7 +19,11 @@ export function createDefaultCapabilities(): Capabilities { return { providers: ['cursor', 'claude-code'], options: { - toolExposure: 'expose-all' + toolExposure: 'expose-all', + security: { + blockedPhrases: ["eval(", "exec(", "execSync("], + allowedCharacters: "[\\u00A0-\\uFFFF]" // allow all printable Unicode; strips only control chars + } }, skills: [ { diff --git a/src/shared/skill-security.ts b/src/shared/skill-security.ts new file mode 100644 index 0000000..12938dc --- /dev/null +++ b/src/shared/skill-security.ts @@ -0,0 +1,183 @@ +import { readFileSync, existsSync } from 'fs'; +import { resolve, dirname } from 'path'; +import type { SecurityOptions } from '../types/capabilities'; + +const RED = '\x1b[31m'; +const RESET = '\x1b[0m'; + +/** + * Error thrown when a blocked phrase is detected in a skill during installation. + */ +export class BlockedPhraseError extends Error { + constructor( + message: string, + public readonly skillId: string, + public readonly filePath: string, + public readonly phrase: string, + public readonly pluginName?: string + ) { + super(message); + this.name = 'BlockedPhraseError'; + } +} + +/** + * Output a blocked phrase error in red and exit the process. + */ +export function reportBlockedPhraseAndExit( + skillId: string, + filePath: string, + phrase: string, + pluginName?: string +): never { + const location = pluginName + ? `Skill "${skillId}" in plugin "${pluginName}"` + : `Skill "${skillId}"`; + const msg = + `\n${RED}✗ Installation blocked: forbidden phrase detected${RESET}\n\n` + + ` ${RED}${location}${RESET}\n` + + ` File: ${filePath}\n` + + ` Forbidden phrase: ${RED}"${phrase}"${RESET}\n\n` + + ` Installation has been stopped. Remove the phrase from the skill or update\n` + + ` your security configuration (options.security.blockedPhrases) and try again.\n`; + console.error(msg); + process.exit(1); +} + +/** + * Characters that are always preserved regardless of the user's allowedCharacters setting. + * Covers standard whitespace (tab, LF, CR) and all printable ASCII (space U+0020 through tilde U+007E). + * This guarantees that skill markdown structure (-, :, ", ', newlines, symbols) is never stripped. + */ +const BASELINE_ALLOWED_INNER = '\\t\\n\\r\\x20-\\x7E'; +const TEXT_EXTENSIONS = new Set([ + '.md', '.txt', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', + '.json', '.yaml', '.yml', '.html', '.css', '.xml' +]); + +/** + * Check if a filename has a text extension (for security checks and sanitization) + */ +export function isTextFile(filename: string): boolean { + const ext = filename.includes('.') ? filename.slice(filename.lastIndexOf('.')) : ''; + return TEXT_EXTENSIONS.has(ext.toLowerCase()); +} + +/** + * Load blocked phrases from security options. + * Returns empty array if no security config or no blocked phrases. + * @param security - Security options from capabilities + * @param capabilitiesFilePath - Full path to capabilities file (for resolving relative file paths) + */ +export function loadBlockedPhrases( + security: SecurityOptions | undefined, + capabilitiesFilePath: string +): string[] { + const blocked = security?.blockedPhrases; + if (blocked === undefined) return []; + + if (Array.isArray(blocked)) { + return blocked + .filter((p): p is string => typeof p === 'string') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + } + + if (typeof blocked === 'object' && blocked !== null && 'file' in blocked && typeof blocked.file === 'string') { + const capabilitiesDir = dirname(capabilitiesFilePath); + const filePath = resolve(capabilitiesDir, blocked.file); + if (!existsSync(filePath)) { + throw new Error( + `Blocked phrases file not found: ${filePath}\n` + + ` Resolved from: ${blocked.file} (relative to ${capabilitiesDir})` + ); + } + const content = readFileSync(filePath, 'utf-8'); + return content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } + + return []; +} + +/** + * Check if content contains any blocked phrase (case-sensitive). + */ +export function checkBlockedPhrases( + content: string, + phrases: string[] +): { blocked: boolean; phrase?: string } { + if (phrases.length === 0) return { blocked: false }; + + for (const phrase of phrases) { + if (content.includes(phrase)) { + return { blocked: true, phrase }; + } + } + return { blocked: false }; +} + +/** + * Sanitize content by replacing disallowed characters with a space. + * + * The baseline (tab, LF, CR, all printable ASCII U+0020–U+007E) is ALWAYS preserved, + * regardless of what allowedCharacters specifies. allowedCharacters is treated as an + * ADDITIONAL allow-list on top of the baseline — users can permit extra Unicode ranges + * but can never restrict below printable ASCII. + * + * @param content - Content to sanitize + * @param allowedCharacters - Extra regex character class content to allow beyond the baseline. + * May include surrounding brackets (e.g. `[\\u00A0-\\uFFFF]`) or just the inner content. + * Pass an empty string to apply baseline-only sanitization (strip all non-ASCII Unicode). + */ +export function sanitizeContent(content: string, allowedCharacters: string): string { + let userInner = allowedCharacters.trim(); + if (userInner.startsWith('[') && userInner.endsWith(']')) { + userInner = userInner.slice(1, -1); + } + + // Combine baseline with user's extra allowances. The baseline ensures that printable + // ASCII and standard whitespace are never stripped, regardless of user configuration. + const combined = BASELINE_ALLOWED_INNER + userInner; + + try { + const regex = new RegExp(`[^${combined}]`, 'g'); + return content.replace(regex, ' '); + } catch { + // Invalid user-provided regex — fall back to baseline only + const fallback = new RegExp(`[^${BASELINE_ALLOWED_INNER}]`, 'g'); + return content.replace(fallback, ' '); + } +} + +/** + * Check if blocked phrases feature is enabled (property must be present). + * Omit or comment out blockedPhrases to disable. + */ +export function isBlockedPhrasesEnabled(security: SecurityOptions | undefined): boolean { + if (!security) return false; + return security.blockedPhrases !== undefined; +} + +/** + * Check if character sanitization is enabled (property must be present). + * Omit or comment out allowedCharacters to disable. + */ +export function isCharacterSanitizationEnabled(security: SecurityOptions | undefined): boolean { + if (!security) return false; + return security.allowedCharacters !== undefined; +} + +/** + * Get the allowed characters value from security options. + * Returns null when character sanitization is disabled (allowedCharacters omitted/malformed). + * An empty string is valid and means "baseline-only" sanitization (strip non-ASCII Unicode). + */ +export function getAllowedCharacters(security: SecurityOptions | undefined): string | null { + const chars = security?.allowedCharacters; + if (chars === undefined) return null; + if (typeof chars !== 'string') return null; + return chars; +} diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index de7e22b..b0a9c69 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -12,6 +12,28 @@ export type { Plugin, SourcePlugin, ResolvedPluginInfo } from './plugin'; */ export type ToolExposureMode = 'expose-all' | 'on-demand'; +/** + * Security options for skill installation. + * Omit a property (or comment it out) to disable that feature. Only present properties are applied. + */ +export interface SecurityOptions { + /** + * Block skill installation if any file contains these phrases. + * Configure inline as string array or via file reference. + * Omit or comment out to disable. + */ + blockedPhrases?: string[] | { file: string }; + /** + * Extra regex character class content for characters to allow BEYOND the hardcoded baseline. + * The baseline (tab, LF, CR, all printable ASCII U+0020–U+007E) is always preserved, so + * markdown-critical characters like `-`, `:`, `"`, `'`, and newlines are never stripped. + * Use this to permit additional Unicode ranges (e.g. `[\\u00A0-\\uFFFF]` for all Unicode). + * Set to an empty string `""` to apply baseline-only sanitization (strips non-ASCII Unicode). + * Omit or comment out to disable sanitization entirely. + */ + allowedCharacters?: string; +} + /** * Configuration options for capabilities behavior */ @@ -21,6 +43,10 @@ export interface CapabilitiesOptions { * @default 'expose-all' */ toolExposure?: ToolExposureMode; + /** + * Security options for skill installation (blocked phrases, character sanitization) + */ + security?: SecurityOptions; } export interface Capabilities { diff --git a/src/version.ts b/src/version.ts index 14875ca..04760b0 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,4 +4,4 @@ * Version extracted from Git tag or package.json */ -export const VERSION = '1.0.0'; +export const VERSION = '1.1.7-dev.1+c256eaa-dirty'; diff --git a/web-ui/home.html b/web-ui/home.html index e4d5d92..a622c66 100644 --- a/web-ui/home.html +++ b/web-ui/home.html @@ -100,6 +100,7 @@ color: var(--text-secondary); font-size: 13px; transition: background-color 0.2s, border-color 0.2s; + margin-left: 16px; } .theme-toggle:hover { @@ -610,6 +611,12 @@

No projects configured

} } + function projectDisplayName(path, fallback) { + if (!path) return fallback || 'Unknown'; + const parts = path.replace(/[/\\]$/, '').split(/[/\\]/); + return parts.filter(Boolean).pop() || fallback || 'Unknown'; + } + function createProjectRow(project) { const row = document.createElement('a'); row.className = 'project-row'; @@ -617,10 +624,11 @@

No projects configured

const lastUpdated = new Date(project.updated_at); const formattedDate = formatDate(lastUpdated); + const displayName = projectDisplayName(project.path, project.id); row.innerHTML = `
-
${escapeHtml(project.id)}
+
${escapeHtml(displayName)}
${escapeHtml(project.path)}
${project.skills_count}
diff --git a/web-ui/index.html b/web-ui/index.html index ccdcb4f..d8cbf97 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -159,7 +159,7 @@ color: var(--text-secondary); font-size: 13px; transition: background-color 0.2s, border-color 0.2s; - margin-left: auto; + margin-left: 16px; } .theme-toggle:hover { @@ -176,7 +176,7 @@ display: flex; align-items: center; gap: 8px; - margin-left: 16px; + margin-left: auto; } .nav-link { @@ -680,6 +680,15 @@ Loading...