// app-docs.ts — Help Center 用のドキュメント参照・ユーザー状態スナップショットツール // // Help piece (pieces/help.yaml) と META_TOOLS から呼ばれる: // - ReadAppDoc({ name }) : symbolic name で project doc を読む // - ListAppDocs() : 利用可能な doc を categorize した一覧 // - GetMyOrchestratorState() : 呼び出しユーザーの sanitized なスナップショット // // セキュリティ: // - REPO_ROOT / DOCS_DIR / PIECES_DIR の allow-list でしかファイルを開かない // - path.resolve した結果がいずれかの allow-list 配下であることを必ず確認 // - 秘密情報 (OAuth client secret, static token, encrypted blob) は GetMyOrchestratorState で // 一切返さない import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; import { resolve, join, dirname, relative, isAbsolute } from 'path'; import { fileURLToPath } from 'url'; import { parse as parseYaml } from 'yaml'; import type Database from 'better-sqlite3'; import { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult } from './core.js'; import { logger } from '../../logger.js'; // ── Paths ───────────────────────────────────────────────────────────────────── // dist/engine/tools/app-docs.js または src/engine/tools/app-docs.ts から // リポジトリルートを解決する (どちらの環境でも 3 階層上) const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const REPO_ROOT = resolve(__dirname, '..', '..', '..'); const DOCS_DIR = join(REPO_ROOT, 'docs'); const PIECES_DIR = join(REPO_ROOT, 'pieces'); const TOOLS_DOCS_DIR = join(DOCS_DIR, 'tools'); // ── Validation ──────────────────────────────────────────────────────────────── const NAME_REGEX = /^[a-zA-Z0-9_\-/.]+$/; const MAX_BYTES = 32 * 1024; // 32 KB cap per doc to keep tokens bounded // ── Injected deps (server.ts calls setAppDocsDeps) ─────────────────────────── interface AppDocsDeps { db: Database.Database; /** * Optional override for the user-folder root (used to introspect * AGENTS.md / memory/ / scripts/ counts). Falls back to './data/users'. */ userFolderRoot?: string; } let _deps: AppDocsDeps | null = null; export function setAppDocsDeps(deps: AppDocsDeps | null): void { _deps = deps; } function getUserFolderRoot(): string { return _deps?.userFolderRoot ?? './data/users'; } // ── Tool definitions ────────────────────────────────────────────────────────── export const TOOL_DEFS: Record = { ReadAppDoc: { type: 'function', function: { name: 'ReadAppDoc', description: 'MAESTRO のプロジェクト内ドキュメント (docs/ / pieces/) を symbolic name で読む。' + ' Help アシスタントが概念や操作手順を答える前のリファレンス参照に使う。' + ' 詳細は ReadToolDoc({ name: "ReadAppDoc" })。', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Symbolic name. 例: "docs/mcp" / "docs/architecture" / "piece/chat" / "tool/browseweb"', }, }, required: ['name'], }, }, }, ListAppDocs: { type: 'function', function: { name: 'ListAppDocs', description: 'MAESTRO のプロジェクト内ドキュメント一覧を category 別に返す (docs/ / pieces/ / tool docs)。' + ' 質問に答える前に関連 doc を探すために使う。' + ' 詳細は ReadToolDoc({ name: "ListAppDocs" })。', parameters: { type: 'object', properties: {}, required: [], }, }, }, GetMyOrchestratorState: { type: 'function', function: { name: 'GetMyOrchestratorState', description: '呼び出しユーザーの現在の Orchestrator 状態 (最近のタスク・MCP 接続・User Folder の構成等) を sanitized な Markdown で返す。' + ' ユーザー固有の質問 (「自分の MCP は何が繋がっている?」「最近何を実行した?」) に答える前に呼ぶ。' + ' トークン・OAuth secret などの秘密情報は一切含まない。' + ' 詳細は ReadToolDoc({ name: "GetMyOrchestratorState" })。', parameters: { type: 'object', properties: {}, required: [], }, }, }, }; // ── Path resolver ───────────────────────────────────────────────────────────── interface ResolvedDoc { path: string; label: string; } /** * Subpaths under docs/ that are internal-only and must never be exposed to * end users via ReadAppDoc / ListAppDocs. * * Match is against the path relative to DOCS_DIR (no leading slash). * A blocked entry ending in '/' blocks the entire subtree. * An entry without '/' blocks that exact file (with or without .md suffix). */ const BLOCKED_DOCS_SUBPATHS = [ 'superpowers/', // implementation plans, internal specs 'maintenance-checklist', // internal ops reference ]; function isBlockedDocsSubpath(relFromDocs: string): boolean { return BLOCKED_DOCS_SUBPATHS.some((blocked) => { if (blocked.endsWith('/')) { return relFromDocs.startsWith(blocked); } return ( relFromDocs === blocked || relFromDocs === `${blocked}.md` ); }); } function sanitizeListedDescription(description: string): string { const referencesInternalDoc = BLOCKED_DOCS_SUBPATHS.some((blocked) => description.includes(blocked.replace(/\/$/, '')), ); return referencesInternalDoc ? '(description omitted: internal reference)' : description; } /** * Map a symbolic doc name to a concrete file path under an allow-listed root. * Returns null on invalid names, attempted traversal, or blocked internal docs. */ export function resolveAppDocPath(name: string): ResolvedDoc | null { if (!name || typeof name !== 'string') return null; if (!NAME_REGEX.test(name)) return null; if (name.includes('..')) return null; // Rejected: internal top-level project docs (CLAUDE.md / AGENTS.md / README.md) if ( name === 'claude-md' || name === 'CLAUDE.md' || name === 'architecture' || name === 'agents-md' || name === 'AGENTS.md' || name === 'readme' || name === 'README.md' ) { return null; } // Piece YAML if (name.startsWith('piece/')) { const piece = name.slice('piece/'.length); if (!piece || piece.includes('/')) return null; if (!/^[a-zA-Z0-9_-]+$/.test(piece)) return null; const path = join(PIECES_DIR, `${piece}.yaml`); if (!isUnderRoot(path, PIECES_DIR)) return null; return { path, label: `pieces/${piece}.yaml` }; } // Tool docs (alias to ReadToolDoc behavior) if (name.startsWith('tool/')) { const tool = name.slice('tool/'.length).toLowerCase(); if (!tool || tool.includes('/')) return null; if (!/^[a-z0-9_-]+$/.test(tool)) return null; const path = join(TOOLS_DOCS_DIR, `${tool}.md`); if (!isUnderRoot(path, TOOLS_DOCS_DIR)) return null; return { path, label: `docs/tools/${tool}.md` }; } // docs/* (auto-append .md if missing) if (name.startsWith('docs/')) { const rel = name.slice('docs/'.length); if (!rel) return null; const withExt = rel.endsWith('.md') ? rel : `${rel}.md`; const resolvedPath = join(DOCS_DIR, withExt); if (!isUnderRoot(resolvedPath, DOCS_DIR)) return null; // Reject blocked internal subpaths const relFromDocs = relative(DOCS_DIR, resolvedPath); if (isBlockedDocsSubpath(relFromDocs)) return null; return { path: resolvedPath, label: `docs/${rel.replace(/\.md$/, '')}` }; } return null; } function isUnderRoot(absolutePath: string, rootDir: string): boolean { // Use path.relative for a portable containment check (no string-prefix games). const rel = relative(rootDir, absolutePath); return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); } // ── ReadAppDoc implementation ──────────────────────────────────────────────── async function executeReadAppDoc( input: Record, _ctx: ToolContext, ): Promise { const name = input['name']; if (typeof name !== 'string' || !name) { return { output: 'ReadAppDoc error: name パラメータが必要です', isError: true }; } const resolved = resolveAppDocPath(name); if (!resolved) { return { output: `ReadAppDoc error: 不正な name "${name}"。` + ' 有効な形式: "docs/" / "piece/" / "tool/"。' + ' ListAppDocs() で利用可能な doc 一覧を取得できます。', isError: true, }; } if (!existsSync(resolved.path)) { return { output: `ReadAppDoc: "${name}" (${resolved.label}) は存在しません。` + ' ListAppDocs() で利用可能な doc 一覧を確認してください。', isError: true, }; } let stat; try { stat = statSync(resolved.path); } catch (e) { return { output: `ReadAppDoc error: stat 失敗: ${(e as Error).message}`, isError: true }; } if (!stat.isFile()) { return { output: `ReadAppDoc error: ${resolved.label} はファイルではありません`, isError: true }; } let raw: string; try { if (stat.size <= MAX_BYTES) { raw = readFileSync(resolved.path, 'utf-8'); } else { // Truncate at MAX_BYTES, walk back to a UTF-8 codepoint boundary const buf = Buffer.alloc(MAX_BYTES); const fd = (await import('fs')).openSync(resolved.path, 'r'); try { (await import('fs')).readSync(fd, buf, 0, MAX_BYTES, 0); } finally { (await import('fs')).closeSync(fd); } let safe = buf.length; while (safe > 0 && (buf[safe - 1]! & 0xc0) === 0x80) safe--; raw = buf.subarray(0, safe).toString('utf-8') + `\n\n[truncated: original was ${stat.size} bytes; ${stat.size - safe} bytes omitted]`; } } catch (e) { return { output: `ReadAppDoc error: ${(e as Error).message}`, isError: true }; } return { output: `# ${resolved.label}\n\n${raw}`, isError: false }; } // ── ListAppDocs implementation ──────────────────────────────────────────────── interface DocEntry { symbolicName: string; description: string; } /** * Extract a one-line description from a Markdown file. * Skips frontmatter and YAML-ish boilerplate, returns the first non-empty * heading or paragraph line. Capped at ~140 chars. */ function extractMarkdownDescription(filePath: string): string { try { const raw = readFileSync(filePath, 'utf-8'); const lines = raw.split('\n'); let inFrontmatter = false; let foundFirstHeading = false; for (let i = 0; i < lines.length && i < 100; i++) { const line = lines[i]!.trim(); if (i === 0 && line === '---') { inFrontmatter = true; continue; } if (inFrontmatter) { if (line === '---') inFrontmatter = false; continue; } if (!line) continue; if (line.startsWith('