import { mkdirSync, writeFileSync, renameSync, unlinkSync, realpathSync, cpSync, rmSync, existsSync, readdirSync, lstatSync } from 'fs'; import { join } from 'path'; import type { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult } from './core.js'; import { VALID_SKILL_NAME } from '../skills.js'; import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../skills-scanner.js'; import { logger } from '../../logger.js'; // ── Injected deps (server.ts / worker.ts call setSkillToolDeps) ───────────── export interface SkillToolDeps { auditLog?: (action: string, detail: object, jobId?: string | null) => void; userFolderRoot: string; } let _deps: SkillToolDeps | null = null; export function setSkillToolDeps(deps: SkillToolDeps | null): void { _deps = deps; } // ── Skill materialization ─────────────────────────────────────────────────── // When the agent ReadSkill's a directory-based skill, copy its files into the // task workspace (`{workspace}/skills/{name}/`) so its scripts are usable in // every Bash sandbox mode (the skill store lives outside the workspace and is // only bind-mounted in the bwrap path). The copy is rw and idempotent per task. const SKILL_MATERIALIZE_MAX_BYTES = 50 * 1024 * 1024; // 50MB /** Total size of `dir` (skipping symlinks), or null once it exceeds `cap`. */ function dirSizeCapped(dir: string, cap: number): number | null { let total = 0; const stack = [dir]; while (stack.length > 0) { const d = stack.pop()!; let entries: string[]; try { entries = readdirSync(d); } catch { continue; } for (const e of entries) { const p = join(d, e); let st; try { st = lstatSync(p); } catch { continue; } if (st.isSymbolicLink()) continue; if (st.isDirectory()) { stack.push(p); continue; } total += st.size; if (total > cap) return null; } } return total; } interface MaterializeResult { ok: boolean; relPath: string; note?: string } /** Copy a skill's source dir into `{workspace}/skills/{name}/` (idempotent). */ export function materializeSkill(srcDir: string, workspacePath: string, skillName: string): MaterializeResult { const relPath = `skills/${skillName}`; const dest = join(workspacePath, 'skills', skillName); if (existsSync(dest)) return { ok: true, relPath }; // already materialized this task if (dirSizeCapped(srcDir, SKILL_MATERIALIZE_MAX_BYTES) === null) { return { ok: false, relPath, note: 'skill exceeds 50MB copy limit' }; } try { mkdirSync(join(workspacePath, 'skills'), { recursive: true }); cpSync(srcDir, dest, { recursive: true, dereference: false, // Skip symlinks so a link inside the skill cannot point outside the workspace. filter: (src) => { try { return !lstatSync(src).isSymbolicLink(); } catch { return false; } }, }); return { ok: true, relPath }; } catch (e) { return { ok: false, relPath, note: `copy failed: ${(e as Error).message}` }; } } // --------------------------------------------------------------------------- // Tool definitions // --------------------------------------------------------------------------- export const TOOL_DEFS: Record = { InstallSkill: { type: 'function', function: { name: 'InstallSkill', description: 'スキル(参照知識: 手順書・ガイド)をインストール。Piece(実行テンプレート)とは異なる。通常は content に SKILL.md 全文を渡す。workspace 内にスキルディレクトリを構築済みの場合のみ sourcePath を使用。', parameters: { type: 'object', properties: { name: { type: 'string', description: 'スキル名 ([a-z0-9_-] のみ)' }, content: { type: 'string', description: 'SKILL.md の全文 (YAML frontmatter + 本文)。通常はこちらを使用' }, sourcePath: { type: 'string', description: 'workspace 内のスキルディレクトリの絶対パス (SKILL.md + scripts/ 等を含む場合のみ)。workspace 外のパスは拒否される' }, scope: { type: 'string', enum: ['system', 'user'], description: 'system=全ユーザー共有 (admin only), user=個人' }, }, required: ['name', 'scope'], }, }, }, ReadSkill: { type: 'function', function: { name: 'ReadSkill', description: 'スキル(参照知識: 手順書・ガイド・規約)の全文を取得する。Piece の定義取得には GetPiece を使うこと。利用可能なスキル一覧はシステムプロンプトの Skills Index を参照。', parameters: { type: 'object', properties: { name: { type: 'string', description: 'スキル名 (Skills Index に表示されている名前)' }, }, required: ['name'], }, }, }, ListSkills: { type: 'function', function: { name: 'ListSkills', description: 'インストール済みスキル(参照知識)の一覧を返す。Piece(実行テンプレート)の一覧は ListPieces を使うこと。', parameters: { type: 'object', properties: {}, required: [], }, }, }, }; // ── InstallSkill implementation ────────────────────────────────────────────── const MAX_FILE_COUNT = 100; const MAX_DEPTH = 3; const MAX_TOTAL_BYTES = 5 * 1024 * 1024; // 5MB function executeInstallSkill( input: Record, ctx: ToolContext, ): ToolResult { const skillName = input['name'] as string | undefined; const content = input['content'] as string | undefined; const sourcePath = input['sourcePath'] as string | undefined; const scope = input['scope'] as string | undefined; if (!skillName || typeof skillName !== 'string') { return { output: 'InstallSkill: "name" parameter is required', isError: true }; } if (!content && !sourcePath) { return { output: 'InstallSkill: either "content" or "sourcePath" is required', isError: true }; } if (content && sourcePath) { return { output: 'InstallSkill: specify either "content" or "sourcePath", not both', isError: true }; } if (scope !== 'system' && scope !== 'user') { return { output: 'InstallSkill: "scope" must be "system" or "user"', isError: true }; } if (!VALID_SKILL_NAME.test(skillName)) { return { output: `InstallSkill: invalid skill name "${skillName}". Only [a-z0-9_-] allowed.`, isError: true }; } if (scope === 'system' && ctx.notesUserRole !== 'admin') { return { output: 'InstallSkill: system-scope install requires admin role', isError: true }; } const catalog = ctx.skillCatalog; if (!catalog) { return { output: 'InstallSkill: skill catalog not available', isError: true }; } const userId = ctx.userId ?? 'local'; // Count check (user scope only) if (scope === 'user') { const userSkillCount = catalog.getForUser(userId).filter(s => s.source === 'user').length; if (userSkillCount >= 50) { return { output: `InstallSkill: user skill limit reached (${userSkillCount}/50)`, isError: true }; } } // --- sourcePath mode: copy directory from workspace --- if (sourcePath) { let realSource: string; let realWorkspace: string; try { realSource = realpathSync(sourcePath); realWorkspace = realpathSync(ctx.workspacePath); } catch (e) { return { output: `InstallSkill: sourcePath does not exist or is not accessible. Use "content" parameter instead to pass SKILL.md text directly.`, isError: true }; } if (!realSource.startsWith(realWorkspace + '/')) { return { output: 'InstallSkill: sourcePath must be inside the task workspace. Use "content" parameter to pass SKILL.md text directly.', isError: true }; } if (!existsSync(join(realSource, 'SKILL.md'))) { return { output: `InstallSkill: SKILL.md not found in ${sourcePath}`, isError: true }; } const stats = getDirStats(realSource); if (stats.fileCount > MAX_FILE_COUNT) { return { output: `InstallSkill: directory contains ${stats.fileCount} files (max ${MAX_FILE_COUNT})`, isError: true }; } if (stats.maxDepth > MAX_DEPTH) { return { output: `InstallSkill: directory depth ${stats.maxDepth} exceeds max ${MAX_DEPTH}`, isError: true }; } if (stats.totalBytes > MAX_TOTAL_BYTES) { return { output: `InstallSkill: directory size ${(stats.totalBytes / 1024 / 1024).toFixed(1)}MB exceeds max 5MB`, isError: true }; } const findings = scanSkillDirectory(realSource); const severity = maxSeverity(findings); if (severity === 'high') { const details = findings.filter(f => f.severity === 'high').slice(0, 5) .map(f => ` - [${f.pattern}] ${f.match} (line ${f.line})`).join('\n'); return { output: `InstallSkill: blocked by security scan:\n${details}`, isError: true }; } const targetBase = scope === 'system' ? catalog.getSystemDir() : catalog.getUserSkillDir(userId); const target = join(targetBase, skillName); const tmpTarget = target + '.tmp-' + Date.now(); try { mkdirSync(targetBase, { recursive: true }); cpSync(realSource, tmpTarget, { recursive: true }); if (existsSync(target)) rmSync(target, { recursive: true, force: true }); const flatPath = join(targetBase, `${skillName}.md`); if (existsSync(flatPath)) unlinkSync(flatPath); renameSync(tmpTarget, target); } catch (e) { try { rmSync(tmpTarget, { recursive: true, force: true }); } catch {} return { output: `InstallSkill: install failed: ${e}`, isError: true }; } if (scope === 'system') { catalog.refreshSystem(); } else { catalog.invalidate(userId); } _deps?.auditLog?.('skill_installed', { skillName, scope, userId, format: 'directory', fileCount: stats.fileCount }, ctx.taskId ?? null); const mediumFindings = findings.filter(f => f.severity === 'medium'); let msg = `InstallSkill: installed "${skillName}" to ${scope} scope (${stats.fileCount} files)`; if (mediumFindings.length > 0) { msg += `\n\nWarnings: ${mediumFindings.length} medium-severity findings`; } return { output: msg, isError: false }; } // --- content mode: create {name}/SKILL.md --- const MAX_BYTES = 64 * 1024; if (Buffer.byteLength(content!, 'utf-8') > MAX_BYTES) { return { output: `InstallSkill: content exceeds 64 KB limit`, isError: true }; } const findings = scanSkillContent(content!); const severity = maxSeverity(findings); if (severity === 'high') { const details = findings.filter(f => f.severity === 'high') .map(f => ` - [high] ${f.pattern}: "${f.match}" (line ${f.line})`).join('\n'); return { output: `InstallSkill: blocked by security scan:\n${details}`, isError: true }; } const targetDir = scope === 'system' ? catalog.getSystemDir() : catalog.getUserSkillDir(userId); const skillDir = join(targetDir, skillName); const tmpDir = join(targetDir, `.${skillName}.tmp.${Date.now()}`); try { mkdirSync(tmpDir, { recursive: true }); writeFileSync(join(tmpDir, 'SKILL.md'), content!, { encoding: 'utf-8', mode: 0o600 }); if (existsSync(skillDir)) rmSync(skillDir, { recursive: true, force: true }); const flatPath = join(targetDir, `${skillName}.md`); if (existsSync(flatPath)) unlinkSync(flatPath); renameSync(tmpDir, skillDir); } catch (err) { try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} return { output: `InstallSkill: write failed: ${(err as Error).message}`, isError: true }; } if (scope === 'system') { catalog.refreshSystem(); } else { catalog.invalidate(userId); } _deps?.auditLog?.('skill_installed', { skillName, scope, userId, scanSeverity: severity, findingsCount: findings.length }, ctx.taskId ?? null); const mediumFindings = findings.filter(f => f.severity === 'medium'); let msg = `InstallSkill: installed "${skillName}" to ${scope} scope`; if (mediumFindings.length > 0) { const warnings = mediumFindings.map(f => ` - [medium] ${f.pattern}: "${f.match}" (line ${f.line})`).join('\n'); msg += `\n\nWarnings (medium severity):\n${warnings}`; } return { output: msg, isError: false }; } // --------------------------------------------------------------------------- // Helpers for InstallSkillFromDir // --------------------------------------------------------------------------- interface DirStats { fileCount: number; totalBytes: number; maxDepth: number; } function getDirStats(dir: string, depth: number = 0): DirStats { const stats: DirStats = { fileCount: 0, totalBytes: 0, maxDepth: depth }; let entries: string[]; try { entries = readdirSync(dir); } catch { return stats; } for (const entry of entries) { const fullPath = join(dir, entry); let st; try { st = lstatSync(fullPath); } catch { continue; } if (st.isSymbolicLink()) continue; if (st.isDirectory()) { const sub = getDirStats(fullPath, depth + 1); stats.fileCount += sub.fileCount; stats.totalBytes += sub.totalBytes; if (sub.maxDepth > stats.maxDepth) stats.maxDepth = sub.maxDepth; } else if (st.isFile()) { stats.fileCount++; stats.totalBytes += st.size; } } return stats; } // --------------------------------------------------------------------------- // Tool execution // --------------------------------------------------------------------------- export function executeSkillTool( name: string, input: Record, ctx: ToolContext, ): ToolResult | null { if (name === 'InstallSkill') { return executeInstallSkill(input, ctx); } if (name === 'ListSkills') { const catalog = ctx.skillCatalog; if (!catalog) return { output: 'Error: skill catalog not available', isError: true }; const userId = ctx.userId ?? 'local'; const entries = catalog.getForUser(userId); if (entries.length === 0) return { output: 'No skills installed.', isError: false }; const lines = entries.map(e => { const dirSuffix = 'dirPath' in e && e.dirPath ? ' (has scripts/)' : ''; return `- **${e.name}** [${e.source}]: ${e.description}${dirSuffix}`; }); return { output: lines.join('\n'), isError: false }; } if (name !== 'ReadSkill') return null; const skillName = input['name'] as string; if (!skillName) { return { output: 'Error: name is required', isError: true }; } const catalog = ctx.skillCatalog; if (!catalog) { return { output: 'Error: skill catalog not available', isError: true }; } const userId = ctx.userId ?? 'local'; const result = catalog.getSkillContent(skillName, userId); if (result === null) { const available = catalog.getForUser(userId).map(s => s.name).join(', '); return { output: `Skill "${skillName}" not found. Available skills: ${available || '(none)'}`, isError: true, }; } if (result.dirPath) { const m = materializeSkill(result.dirPath, ctx.workspacePath, skillName); const loc = m.ok ? `このスキルのファイルは workspace の \`${m.relPath}/\` に配置しました(例: \`${m.relPath}/scripts/...\`)。スクリプトはこの相対パスで実行できます。` : `(注: スキルのファイルを workspace にコピーできませんでした: ${m.note}。SKILL.md の手順は以下を参照)`; return { output: `${loc}\n\n${result.content}`, isError: false }; } return { output: result.content, isError: false }; }