374 lines
15 KiB
TypeScript
374 lines
15 KiB
TypeScript
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 };
|
||
}
|
||
|