maestro/src/engine/tools/skills.ts
2026-06-03 05:08:00 +00:00

374 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, ToolDef> = {
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<string, unknown>,
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<string, unknown>,
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 };
}