/** * tool-pack-gate.test.ts — 群ごと止める仕掛けの試験(#1126 段 C)。 * * ここで守りたいこと。 * * 1. **設定ファイルから、エージェントが受け取るツール一覧まで通す。** * 設定を読む純関数だけを固めても意味がない。実際に config.yaml を書いて、 * 実際の getToolDefs / loadBuiltinEntries / resolveWorkspaceTools の返りを見る。 * ここが薄いと「設定を効かせる 1 行」を消しても全部緑のままになる。 * * 2. **旧ルートが無傷であること。**台帳に載っていない群は今までどおり全部 * 使える。何も止めていない状態の実効値が、この仕掛けを入れる前と 1 つも * 変わらないことを、ツール名の実物で見る。 * * 3. **走行中に入れ替わらないこと。**判定はプロセスに 1 回。設定ファイルを * 書き換えても、同じプロセスの中ではツール一覧が動かない。動くのは * 読み込み直した(=再起動した)ときだけ、という形で両側を見る。 */ import { describe, it, expect, afterEach } from 'vitest'; import { mkdtempSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { TOOL_PACKS, listToolPackIds, isKnownToolPackId, normalizeToolModuleSpecifier, toolPackIdOfModule, } from './tool-pack-ledger.js'; import { getToolPackGate, getToolPackDecisions, isToolModuleDisabled, isToolPackDisabled, _resetToolPackGateForTests, _setToolPackConfigReaderForTests, } from './tool-pack-gate.js'; import { listToolCategories, getToolCategoryMap, META_TOOLS, SENSITIVE_TOOLS, _resetToolCategoryCacheForTests, } from './tool-categories.js'; import { _resetToolModuleCacheForTests, _setToolModuleManifestOverridesForTests, listRegisteredToolModuleSpecifiers, loadRegisteredToolModules, } from './module-manifest.js'; import { resolveWorkspaceTools } from '../workspace-tool-policy.js'; import { validateConfig, loadConfig } from '../../config.js'; // ───────────────────────────────────────────────────────────────────────────── // 補助 // ───────────────────────────────────────────────────────────────────────────── /** 群の入り切りを差し替えて、下流のキャッシュを全部捨てる */ function setDisabledPacks(disabled: string[]): void { _setToolPackConfigReaderForTests(() => ({ toolPacks: { disabled } })); _resetToolCategoryCacheForTests(); _resetToolModuleCacheForTests(); } /** 差し替えを戻す */ function restoreGate(): void { _setToolPackConfigReaderForTests(null); _resetToolPackGateForTests(); _resetToolCategoryCacheForTests(); _resetToolModuleCacheForTests(); } const X_TOOLS = ['XSearch', 'XUserPosts', 'XPostDetail', 'XFetchCardMedia', 'XTimeline']; const CONTROL_TOOLS = ['WebSearch', 'Read', 'SQLite', 'RenderMermaid']; afterEach(() => { restoreGate(); }); // ───────────────────────────────────────────────────────────────────────────── // 1. 台帳そのもの // ───────────────────────────────────────────────────────────────────────────── describe('台帳', () => { it('名前が重複しない', () => { const ids = listToolPackIds(); expect(new Set(ids).size).toBe(ids.length); }); it('載っているモジュールは実在し、空でない TOOL_DEFS を持つ', async () => { // try/catch を書かないこと。書いた瞬間、台帳の綴り間違いを見逃す試験になる。 for (const pack of TOOL_PACKS) { for (const specifier of pack.modules) { const mod = (await import(specifier)) as { TOOL_DEFS?: Record }; expect(Object.keys(mod.TOOL_DEFS ?? {}).length, `${pack.id} / ${specifier}`).toBeGreaterThan(0); } } }); it('モジュール指定はどちらの書き方でも同じ群に解決する', () => { expect(normalizeToolModuleSpecifier('../engine/tools/x.js')).toBe('./x.js'); expect(toolPackIdOfModule('./x.js')).toBe('x'); expect(toolPackIdOfModule('../engine/tools/x.js')).toBe('x'); expect(toolPackIdOfModule('./core.js')).toBeNull(); }); it('台帳の群は、中央の常時利用一覧・既定 OFF 一覧に名前を出していない', async () => { // 出していると、群を止めても中央の一覧経由で名前が許可一覧に残る。 // 台帳に群を足す人がこの境目を踏み抜かないための歯止め。 const central = new Set([...META_TOOLS, ...SENSITIVE_TOOLS]); const offenders: string[] = []; for (const pack of TOOL_PACKS) { for (const specifier of pack.modules) { const mod = (await import(specifier)) as { TOOL_DEFS?: Record }; for (const name of Object.keys(mod.TOOL_DEFS ?? {})) { if (central.has(name)) offenders.push(`${pack.id}:${name}`); } } } expect(offenders).toEqual([]); }); }); // ───────────────────────────────────────────────────────────────────────────── // 2. 旧ルート — 台帳に載っていない群は今までどおり全部使える // ───────────────────────────────────────────────────────────────────────────── describe('旧ルート', () => { it('何も止めていなければ、台帳の群も台帳外の群も全部使える', async () => { setDisabledPacks([]); const { allowedTools } = await resolveWorkspaceTools({}); for (const name of [...X_TOOLS, ...CONTROL_TOOLS, 'GetYouTubeTranscript', 'SearchPlaces', 'SearchAmazon', 'TranscribeAudio', 'SearchMicrosoftLearn', 'BuildPptx']) { expect(allowedTools, name).toContain(name); } }); it('設定そのものが無い(tool_packs 未記載)ときも全部使える', async () => { _setToolPackConfigReaderForTests(() => ({})); _resetToolCategoryCacheForTests(); _resetToolModuleCacheForTests(); const { allowedTools } = await resolveWorkspaceTools({}); expect(allowedTools).toContain('XSearch'); expect(allowedTools).toContain('GetYouTubeTranscript'); expect(getToolPackGate().disabledPackIds.size).toBe(0); }); it('1 つ止めても、台帳外の群は 1 つも減らない', async () => { setDisabledPacks([]); const before = new Set((await resolveWorkspaceTools({})).allowedTools); setDisabledPacks(['x']); const after = new Set((await resolveWorkspaceTools({})).allowedTools); const removed = [...before].filter((n) => !after.has(n)).sort(); expect(removed).toEqual([...X_TOOLS].sort()); // 逆向き(勝手に増える)も見る expect([...after].filter((n) => !before.has(n))).toEqual([]); }); it('止めた群の分類だけが消え、他の分類は残る', async () => { setDisabledPacks([]); const before = await listToolCategories(); setDisabledPacks(['x', 'youtube']); const after = await listToolCategories(); expect([...before].filter((c) => !after.includes(c)).sort()).toEqual(['x', 'youtube']); expect(after).toContain('core'); expect(after).toContain('web'); expect(after).toContain('browser'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 5. 層の向き — 環境で外したものはワークスペースで入れられない // ───────────────────────────────────────────────────────────────────────────── describe('層の向き', () => { it('環境で止めた分類は、ワークスペースの「入れたもの」に書いてあっても入らない', async () => { // speak は分類そのものが既定 OFF。ワークスペース側で 1 回入れれば使える // ——という既存の道が、環境で止めたときに塞がることを見る。 setDisabledPacks([]); const on = await resolveWorkspaceTools({ enabledSensitive: ['speak'] }); expect(on.allowedTools, '前提: 環境で止めていなければ入る').toContain('Speak'); setDisabledPacks(['speak']); const off = await resolveWorkspaceTools({ enabledSensitive: ['speak'] }); expect(off.allowedTools).not.toContain('Speak'); }); it('環境で止めた分類は、ワークスペースの設定画面の分類一覧にも出ない', async () => { setDisabledPacks(['speak', 'x']); const cats = await listToolCategories(); expect(cats).not.toContain('speak'); expect(cats).not.toContain('x'); }); it('止めた群のツールは分類の地図から消える(下流が全部ここを見る)', async () => { setDisabledPacks(['amazon']); const map = await getToolCategoryMap(); expect(map.has('SearchAmazon')).toBe(false); expect(map.get('WebSearch')).toBe('web'); }); it('環境で止めた群のツールは、個別に「入れたもの」へ書いても入らない', async () => { // いま台帳の群は 1 つも「既定 OFF のツール」を申告していない(上の歯止めで // そう縛ってある)。だから、これから誰かが申告したときに層の向きが崩れないか // ——を、申告を差し込んで先に確かめる。 // 差し込みが無いと、この経路は 1 行消しても全部緑のままになる。 _setToolModuleManifestOverridesForTests([ { id: 'x', category: 'x', sensitiveTools: ['XSearch'] }, ]); try { setDisabledPacks([]); const on = await resolveWorkspaceTools({ enabledSensitive: ['XSearch'] }); expect(on.allowedTools, '前提: 止めていなければ個別の有効化で入る').toContain('XSearch'); setDisabledPacks(['x']); const off = await resolveWorkspaceTools({ enabledSensitive: ['XSearch'] }); expect(off.allowedTools).not.toContain('XSearch'); } finally { _setToolModuleManifestOverridesForTests(null); } }); it('止めた群のモジュールは、申告の収集対象からも外れる', async () => { setDisabledPacks(['youtube']); expect(listRegisteredToolModuleSpecifiers()).not.toContain('./youtube.js'); expect(listRegisteredToolModuleSpecifiers()).toContain('./web.js'); }); it('保存済みの設定を持つワークスペースは、環境で止めたあとも設定を保存できる', async () => { // 保存の検査に使う一覧だけは、環境で止めた分類も「知っている名前」として // 数える。数えないと、群を止める前に開かれたままの古いタブと、API を // 直接叩くクライアントからの保存が、綴り間違いと同じ扱いで弾かれる。 setDisabledPacks(['x']); expect(await listToolCategories({ includeEnvDisabled: true })).toContain('x'); expect(await listToolCategories()).not.toContain('x'); }); // 覚え書き(メモ化)は 2 本立てで、フィルタ済みと止めた群込みを別の鍵で持つ。 // **本番はフィルタ済みが必ず先に埋まる**——movement ごとに getToolDefs と // resolveWorkspaceTools が読むため。設定画面の保存はそのあとに来る。 // 鍵が 1 本に崩れると、保存の検査がフィルタ済みの一覧を受け取り、上で // 塞いだ罠がそのまま戻る。だから**この順番で**呼んで縛る。 it('フィルタ済みを先に読んでも、includeEnvDisabled は止めた群のツールを返す', async () => { setDisabledPacks(['x']); const filtered = await getToolCategoryMap(); // 本番はこっちが先 expect(filtered.has('XSearch')).toBe(false); const all = await getToolCategoryMap({ includeEnvDisabled: true }); expect(all.has('XSearch'), '覚え書きの鍵が 1 本に崩れている').toBe(true); }); it('フィルタ済みを先に読んでも、includeEnvDisabled は止めた群のモジュールを読み込む', async () => { setDisabledPacks(['x']); const filtered = await loadRegisteredToolModules(); // 本番はこっちが先 expect(filtered.loaded.some((l) => l.specifier === './x.js')).toBe(false); const all = await loadRegisteredToolModules({ includeEnvDisabled: true }); expect(all.loaded.some((l) => l.specifier === './x.js'), '覚え書きの鍵が 1 本に崩れている').toBe(true); }); }); // ───────────────────────────────────────────────────────────────────────────── // 6. 知らない名前を黙って無視しない // ───────────────────────────────────────────────────────────────────────────── describe('知らない群の名前', () => { it('判定の一覧に「知らない名前」として残る', () => { setDisabledPacks(['x', 'nosuchpack']); const gate = getToolPackGate(); expect(gate.unknownPackIds).toEqual(['nosuchpack']); const unknown = gate.decisions.filter((d) => d.status === 'unknown'); expect(unknown).toHaveLength(1); expect(unknown[0]!.packId).toBe('nosuchpack'); expect(unknown[0]!.reason).toBe('unknown-pack-id'); }); it('知らない名前を書いても、他の群の入り切りは正しく効く', () => { setDisabledPacks(['nosuchpack', 'x']); expect(isToolPackDisabled('x')).toBe(true); expect(isToolPackDisabled('youtube')).toBe(false); expect(isToolPackDisabled('nosuchpack')).toBe(false); }); it('設定ファイルを読ませたときに、検査が知らない名前を指摘する', () => { // 設定ファイルの実物を読ませる。手で組み立てた形だけを検査に通すと、 // スネークケースの読み替え(tool_packs → toolPacks)が抜けていても通る。 const dir = mkdtempSync(join(tmpdir(), 'tool-packs-cfg-')); try { const bad = join(dir, 'bad.yaml'); writeFileSync(bad, 'tool_packs:\n disabled:\n - x\n - nosuchpack\n', 'utf-8'); const errors = validateConfig(loadConfig(bad)); expect(errors.some((e) => e.includes('nosuchpack'))).toBe(true); const good = join(dir, 'good.yaml'); writeFileSync(good, 'tool_packs:\n disabled:\n - x\n', 'utf-8'); const ok = validateConfig(loadConfig(good)); expect(ok.some((e) => e.includes('toolPacks'))).toBe(false); } finally { rmSync(dir, { recursive: true, force: true }); } }); it('設定ファイルのスネークケースが、そのまま群の入り切りとして効く', () => { const dir = mkdtempSync(join(tmpdir(), 'tool-packs-cfg-')); try { const p = join(dir, 'c.yaml'); writeFileSync(p, 'tool_packs:\n disabled:\n - ms-learn\n', 'utf-8'); expect(loadConfig(p).toolPacks?.disabled).toEqual(['ms-learn']); } finally { rmSync(dir, { recursive: true, force: true }); } }); it('台帳にある名前は既知と判定される', () => { for (const id of listToolPackIds()) expect(isKnownToolPackId(id)).toBe(true); expect(isKnownToolPackId('nosuchpack')).toBe(false); }); }); // ───────────────────────────────────────────────────────────────────────────── // 7. 判定が 1 か所に集まっていること(あとから群ごとの記録を出せる形) // ───────────────────────────────────────────────────────────────────────────── describe('判定の記録', () => { it('台帳の全群について、いつ・なぜ・誰の設定で決まったかが 1 か所に揃う', () => { setDisabledPacks(['x']); const decisions = getToolPackDecisions(); const known = decisions.filter((d) => d.status !== 'unknown'); expect(known.map((d) => d.packId).sort()).toEqual(listToolPackIds().sort()); for (const d of known) { expect(d.source).toContain('tool_packs.disabled'); expect(Number.isNaN(Date.parse(d.decidedAt))).toBe(false); } const x = known.find((d) => d.packId === 'x')!; expect(x.status).toBe('disabled'); expect(x.reason).toBe('disabled-by-config'); const yt = known.find((d) => d.packId === 'youtube')!; expect(yt.status).toBe('enabled'); expect(yt.reason).toBe('not-listed-in-config'); }); it('同じプロセスの中では、判定は何度読んでも同じもの', () => { setDisabledPacks(['x']); const first = getToolPackDecisions(); const second = getToolPackDecisions(); expect(second).toBe(first); expect(isToolModuleDisabled('./x.js')).toBe(true); expect(isToolModuleDisabled('../engine/tools/x.js')).toBe(true); }); });