/** * Capability dispatcher — capability ID + params を受けて execute() を呼ぶ。 * * - permissions チェックを通過した capability のみ実行 * - 未登録 / permission 拒否 / 実行失敗を構造化エラーで区別して返す * - execute() は同期 / 非同期どちらでも対応 * * Phase 2 A-3.2 で AI tool dispatcher (tool_use → これ → tool_result) の中核 * として呼ばれる。Phase 1 の `ai.json5` permissions と同じスキーマで照合する * ので、ユーザーが `safe` プリセットを選んでいれば書き込み系は自動 deny される。 */ import { nextTick } from 'vue' import type { Capability } from '@/capabilities/types' import { COLUMN_LABELS } from '@/columns/registry' import { useAiConfig } from '@/composables/useAiConfig' import { accountTargetId, columnTargetId, navbarTargetId, noteTargetId, useSpotlightStore, windowTargetId, } from '@/composables/useSpotlight' import { i18n } from '@/i18n' import { loopbackReasonLabel, notifyLoopbackDenialInteraction, notifyPluginDenialInteraction, recordPluginDenial, } from '@/permissions/pluginDenials' import { type Principal, principalActorLabel } from '@/permissions/principal' import type { PermissionKey } from '@/permissions/schema' import { addConfirmSkip, confirmSkipKey, confirmSkipScope, isConfirmSkipped, resolveFor, whenPermissionsReady, } from '@/permissions/store' import { failureMessage } from '@/services/capabilityFailure' import { isReadOnlyPermissions } from '@/services/petActivity' import { getAccountLabel, useAccountsStore } from '@/stores/accounts' import { useAiActivity } from '@/stores/aiActivity' import { logSourceOfPrincipal, useAiScriptLogsStore, } from '@/stores/aiscriptLogs' import { type ConfirmDecision, type ConfirmOptions, useConfirm, } from '@/stores/confirm' import { useDeckStore } from '@/stores/deck' import { useWindowsStore } from '@/stores/windows' import { WINDOW_LABELS } from '@/windows/registry' import { pickAccountId } from './accountContext' import { sanitizeToolName } from './identifier' import { gateLoopbackFetch, loopbackReasonFromCode, loopbackRequestLine, } from './loopbackGate' import { getCapability, listCapabilities } from './registry' import type { CapabilityContext } from './types' export type DispatchErrorCode = | 'unknown_capability' | 'permission_denied' | 'preflight_failed' | 'execute_failed' | 'user_cancelled' export type DispatchResult = | { ok: true; result: unknown } | { ok: false; code: DispatchErrorCode; error: string } /** * テスト容易性のために confirm 関数を差し替えられる。production では * 未指定で `useConfirm().confirmWithDecision` が使われる。テストは * `() => Promise.resolve({ accepted: true, remember: false })` 等で * skip / accept / reject / remember を制御する。 */ export interface DispatchOptions { confirmFn?: (opts: ConfirmOptions) => Promise /** * notecore が確認要求を出して許可を得た実行 (#1133)。ここでは確認を出さない * (要否の判定と要求は notecore、内容の組み立ては `previewConfirmation`)。 * 権限検査と preflight は写しとして通す。 */ preConfirmed?: boolean /** 呼び出し元のセッションが tainted (notecore の判定) */ tainted?: boolean /** 返す内容にラベル付きのメモ / skill が含まれる申告の受け口 */ markTainted?: () => void /** * 受信箱カード (無人の書込意図, #1133) の実行: 確認を必ず出し、「次から * 確認しない」の記憶は見ない・出さない */ forceConfirm?: boolean /** 確認ダイアログの本文の先頭に添える一文 (生成元 / 汚染の明示) */ confirmNote?: string /** * 権限の判定と確認までを行い、実行はしない (#1191)。手元の CLI (ACP) の許可要求の * 段階で NoteDeck の確認を出すために使う。承認なら `{ ok: true, result: null }`、 * 拒否 / 権限なし / preflight 失敗は通常の実行と同じエラー */ confirmOnly?: boolean } /** notecore の確認要求に同梱する内容 (`ai/confirm-preview` の応答) */ export interface ConfirmPreview { /** false = この引数なら確認は要らない (no-op など)。notecore はそのまま実行する */ needsConfirmation: boolean options?: ConfirmOptions /** 「次から確認しない」を出してよいか (クロスアカウントでは出さない) */ allowRemember: boolean } /** * dispatch の呼び出しコンテキスト。principal (誰が実行を要求しているか #712) を * 必須で受け取る。従来の「呼び出し側が permissions セットをすり替えた AiConfig * を渡す」暗黙表現を置換したもの。 */ export interface DispatchContext { principal: Principal /** * 呼び出し文脈のアカウント (#821)。Nd:call (プラグインのアクション実行中) * が埋め、per-account の AI カラムはカラムのアカウントを入れる。未指定 = * アカウント文脈なし (capability 側は params.accountId を必須にする、#941)。 */ accountId?: string | null /** * この dispatch を引き起こした上流の principal (#1099)。plugin が登録した * capability を AI / 外部トークンが呼ぶと、handler 内の Nd:call は plugin * principal で dispatch されるが、実効権限は「呼び出し元 ∩ 実行体」で決める * — principal の置換で権限が広がらない。ここに積まれた principal 全員の許可 * が要る (user はプロファイルを持たないので無視される)。 */ onBehalfOf?: readonly Principal[] /** * 実行の起点 (#1293)。`'ui'` = 人が押した入口 (ノート / ユーザー / 投稿フォームの * アクション、コマンドパレット) の中、それ以外は `'other'`。手元のポートへの * 同意ダイアログを出すか決めるだけで、認可の境界ではない */ origin?: 'ui' | 'other' /** principal が plugin のとき、動かしている本文の hash (#1293) */ pluginBodyHash?: string } /** エラー文用: 実行体と (あれば) 呼び出し元を並べる。 */ function describePrincipals(ctx: DispatchContext): string { const callers = (ctx.onBehalfOf ?? []) .filter((p) => p.kind !== 'user') .map((p) => p.kind) return callers.length > 0 ? `${ctx.principal.kind} on behalf of ${callers.join(', ')}` : ctx.principal.kind } /** * Capability を呼ぶ。permissions が通れば (必要なら confirm) execute → 結果を返す。 * AI tool calling / slash command / HTTP API / AiScript 経路の共通入口。 */ export async function dispatchCapability( capabilityId: string, params: Record | undefined, ctx: DispatchContext, options?: DispatchOptions, ): Promise { // 起動直後は permissions.json5 の読込 (async) が終わる前に autoRun // ウィジェット等がここへ到達しうる。デフォルト値での誤判定 (「今後確認 // しない」記憶の取りこぼし #716) を防ぐため、読込完了を待ってから判定する。 await whenPermissionsReady() // capabilityId は (a) registry に格納されている dotted id (`time.now`) か、 // (b) Anthropic / OpenAI が返す sanitized name (`time_now`) のどちらかで // 来る可能性がある。前者は直接 lookup、後者は逆引きで解決する。 let cap = getCapability(capabilityId) if (!cap) { cap = listCapabilities().find( (c) => sanitizeToolName(c.id) === capabilityId, ) } if (!cap) { return { ok: false, code: 'unknown_capability', error: `Capability "${capabilityId}" is not registered`, } } // クロスアカウント実行の検査 (#777): actsAsAccount 宣言付き capability で、 // 非 user principal が呼び出し文脈 (ctx.accountId) と異なるアカウントを // 明示指定したときだけ true。ctx.accountId 由来の実行 (#821 — 本人がその // ノートのメニューから起動した文脈) は対象外。文脈アカウントが無い経路 // (全アカウントの AI カラム / HEARTBEAT / 全体プラグイン) は「別のアカウント // として振る舞う」相手がいないので対象外 (#941 でアクティブアカウントへの // 暗黙フォールバックを廃止した) const crossAccountId = cap.actsAsAccount && ctx.principal.kind !== 'user' && ctx.accountId ? pickAccountId(params?.accountId) : undefined const crossAccount = crossAccountId !== undefined && crossAccountId !== ctx.accountId if (crossAccount) { const actAsDenied = checkPermissions( ['account.actAs'], ctx.principal, ctx.onBehalfOf, ) if (actAsDenied.length > 0) { if (ctx.principal.kind === 'plugin') { recordPluginDenial(ctx.principal.pluginId, cap.id, actAsDenied) if (ctx.accountId) { notifyPluginDenialInteraction(ctx.principal.name, actAsDenied) } } return { ok: false, code: 'permission_denied', error: `Permission denied for ${capabilityId}: cross-account execution (accountId "${crossAccountId}") requires [account.actAs] not allowed for principal "${describePrincipals(ctx)}" (permissions.json5)`, } } } const denied = checkPermissions( cap.permissions ?? [], ctx.principal, ctx.onBehalfOf, ) if (denied.length > 0) { // plugin の拒否はプラグインカラムの拒否バッジに流す (#712 §8.4 — 破壊的 // 変更をリリースノート依存にしない in-app 導線)。UI 操作起点 // (note/user/post_form アクション = アカウント文脈付き) の拒否だけは // その場で toast する — 「クリックしたのに無言」を防ぐ。自律実行の // 高頻度拒否は従来どおりバッジのみ (spam 回避) if (ctx.principal.kind === 'plugin') { recordPluginDenial(ctx.principal.pluginId, cap.id, denied) if (ctx.accountId) { notifyPluginDenialInteraction(ctx.principal.name, denied) } } return { ok: false, code: 'permission_denied', error: `Permission denied for ${capabilityId}: required [${denied.join(', ')}] not allowed for principal "${describePrincipals(ctx)}" (permissions.json5)`, } } // preflight (入力検証 — 確認ダイアログより前に走る) if (cap.preflight) { const failure = await cap.preflight(params) if (failure) { return { ok: false, code: 'preflight_failed', error: failure.error, } } } const capCtx: CapabilityContext = { aiConfig: useAiConfig().config.value, principal: ctx.principal, ...(ctx.accountId ? { accountId: ctx.accountId } : {}), // 内側の判定 (tasks.run / plugin handler) にも連鎖を渡す (#1099) ...(ctx.onBehalfOf?.length ? { onBehalfOf: ctx.onBehalfOf } : {}), ...(options?.tainted ? { tainted: true } : {}), ...(options?.markTainted ? { markTainted: options.markTainted } : {}), ...(ctx.pluginBodyHash ? { pluginBodyHash: ctx.pluginBodyHash } : {}), } // 手元のポートへの要求 (#1293): プラグインの http.fetch が loopback を指すときだけ // 宣言 / 同意 / 本文の鮮度を見る。同意ダイアログで承認された要求は、その承認が // 確認を兼ねる。拒否はバッジ (理由つき) と運用ログに残す const loopback = cap.id === 'http.fetch' && ctx.principal.kind === 'plugin' ? await gateLoopbackFetch(params, ctx, { preConfirmed: options?.preConfirmed === true, confirmFn: options?.confirmFn, }) : null if (loopback?.kind === 'denied' && ctx.principal.kind === 'plugin') { const reason = loopback.reason recordPluginDenial(ctx.principal.pluginId, cap.id, [], reason) logLoopback(ctx.principal, params, `denied: ${reason}`) if (ctx.origin === 'ui') { notifyLoopbackDenialInteraction( ctx.principal.pluginId, ctx.principal.name, reason, ) } return { ok: false, code: 'permission_denied', error: `Loopback access denied for ${capabilityId} (port ${loopback.port}): ${loopbackReasonLabel(reason)}`, } } if (loopback?.kind === 'cancelled') { logLoopback(ctx.principal, params, 'cancelled') return { ok: false, code: 'user_cancelled', error: `User cancelled loopback access for ${capabilityId}`, } } const loopbackPort = loopback && loopback.kind !== 'pass' ? loopback.port : (loopback?.port ?? null) // 確認ダイアログ (write 系などで requiresConfirmation: true)。 // クロスアカウント実行は requiresConfirmation 未宣言でも必ず確認する。 // notecore が既に許可を得ている実行 (#1133) と、同意ダイアログで承認された // loopback 宛の要求では出さない。 const prepared = options?.preConfirmed || loopback?.kind === 'consented' ? null : await prepareConfirmation(cap, params, capCtx, ctx, { crossAccount, crossAccountId, force: options?.forceConfirm === true, note: options?.confirmNote, }) if (prepared) { const { confirmOpts, skipScope } = prepared // 無人実行 (HEARTBEAT) は承認を待たない (#1106 §4.8)。従来は誰も見ていない // モーダルを開いたまま run が止まり、後続 tick が already-running で // スキップされていた。確認が要る操作はその場で拒否して AI に返す。 // 書き込み意図を下書き / 受信箱カードに変える最終形は #1133。 if (ctx.principal.kind === 'ai.heartbeat') { return { ok: false, code: 'user_cancelled', error: `Unattended HEARTBEAT does not run operations that require confirmation: ${capabilityId}`, } } const confirmFn = options?.confirmFn ?? useConfirm().confirmWithDecision // ペット (#1080): AI の要求で承認を待っている間は waiting const endWaiting = isAiPrincipal(ctx.principal) ? useAiActivity().begin('waiting') : null let decision: Awaited> try { decision = await confirmFn(confirmOpts) } finally { endWaiting?.() } if (!decision.accepted) { return { ok: false, code: 'user_cancelled', error: `User cancelled execution of ${capabilityId}`, } } // 「次回から確認しない」が ON のまま許可されたら次回以降に効かせる。 // capability 固有実装 (vault の接続信頼) があればそちらへ委ね、 // 無ければ scope × capability 単位で permissions.json5 に記憶する。 if (decision.remember && !crossAccount) { if (cap.onConfirmRemember) { await cap.onConfirmRemember(params, capCtx) } else if (skipScope !== null) { addConfirmSkip(skipScope, cap.id) } } } if (options?.confirmOnly) { return { ok: true, result: null } } // ペット (#1080): 読み取り系は review、それ以外は running const endExecute = isAiPrincipal(ctx.principal) ? useAiActivity().begin( isReadOnlyPermissions(cap.permissions ?? []) ? 'review' : 'running', ) : null try { const result = await cap.execute(params, capCtx) if (loopbackPort !== null) { const status = (result as { status?: unknown } | null)?.status logLoopback(ctx.principal, params, `status ${String(status ?? '?')}`) } // AI 操作の可視化: 成功時のみ、対応する UI 要素を一時的に光らせる。 // DOM 更新後 (navbar に新 item が反映されてから) に highlight する。 // 防御: spotlight 副作用が万一失敗しても本体 (AI flow) に影響しないよう // catch + Promise rejection 無視で完全分離する。 void nextTick(() => { try { emitSpotlightFromCapability(cap.id, params, result, ctx.principal) } catch (err) { console.warn('[dispatcher] spotlight emit failed (ignored):', err) } }).catch((err) => { console.warn('[dispatcher] spotlight nextTick rejected (ignored):', err) }) return { ok: true, result } } catch (e) { // Rust が理由つきで拒んだ loopback 宛の要求 (#1293) はバッジに写す const reason = loopbackReasonFromCode( (e as { code?: unknown } | null)?.code, ) if (reason && ctx.principal.kind === 'plugin') { recordPluginDenial(ctx.principal.pluginId, cap.id, [], reason) logLoopback(ctx.principal, params, `denied: ${reason}`) if (ctx.origin === 'ui') { notifyLoopbackDenialInteraction( ctx.principal.pluginId, ctx.principal.name, reason, ) } } else if (loopbackPort !== null) { logLoopback(ctx.principal, params, 'failed') } return { ok: false, code: 'execute_failed', error: failureMessage(e), } } finally { endExecute?.() } } /** * loopback 宛の要求は結果にかかわらずプラグインの運用ログに 1 行残す (#1293): * method / path (query 無し) / status または拒否の理由。runId 0 = 実行の外 */ function logLoopback( principal: Principal, params: Record | undefined, outcome: string, ): void { const { source, sourceId, name } = logSourceOfPrincipal(principal) useAiScriptLogsStore().record( source, sourceId, name, 0, 'system', `loopback ${loopbackRequestLine(params)} -> ${outcome}`, ) } function isAiPrincipal(principal: Principal): boolean { return principal.kind === 'ai.chat' || principal.kind === 'ai.heartbeat' } /** * Capability 実行成功後に、対応する UI 要素を spotlight する。 * 可視化は rogue plugin / rogue token を検知する security 手段なので、 * user 以外の全 principal で発火し、帰属ラベル (誰がやったか) を正確に出す * (#712 §3.3)。user 本人の操作には帰属表示は不要なので発火しない。 */ export function emitSpotlightFromCapability( capId: string, params: Record | undefined, result: unknown, principal: Principal, ): void { const actor = principalActorLabel(principal) if (!actor) return if (capId === 'column.add') { // 新規追加されたカラム本体 (= bottombar / mobile nav のタブが反応する) // を spotlight する。ナビバー (= サイドバースロット) は AI による // 新規カラム追加では破壊的になり得るので対象にしない (#feedback)。 const r = result as { id?: string; type?: string } | null const newColumnId = r?.id const type = r?.type ?? (params?.type as string | undefined) if (newColumnId && type) { const label = COLUMN_LABELS[type] ?? type const targetId = columnTargetId(newColumnId) useSpotlightStore().highlight(targetId, { label: i18n.tsx._dispatcher.columnAdded({ actor, label }), }) } else { console.warn( '[spotlight] column.add succeeded but cannot derive target:', { newColumnId, type, result, params }, ) } } else if (capId === 'sidebar.toggle') { // サイドバースロットを開閉した → ナビバーボタンが反応する target const r = result as { type?: string; opened?: boolean } | null const type = r?.type ?? (params?.type as string | undefined) const accountId = (params?.accountId as string | null | undefined) ?? null // 「開いた」ときだけ spotlight (閉じた = もうそのボタンを見る理由がない) if (r?.opened && type) { const label = COLUMN_LABELS[type] ?? type const targetId = navbarTargetId(type, accountId) useSpotlightStore().highlight(targetId, { label: i18n.tsx._dispatcher.columnOpenedInSidebar({ actor, label }), }) } } else if (capId === 'column.remove') { // 削除は対象 DOM が消えるので視覚 spotlight 無し。SR テキストのみ announce。 // type は ID から逆引きできない (既に削除済み) → 汎用文 useSpotlightStore().announce(i18n.tsx._dispatcher.columnRemoved({ actor })) } else if (capId === 'column.move') { // 移動後の位置で該当カラムタブを光らせる const r = result as { columnId?: string } | null if (r?.columnId) { const col = useDeckStore().getColumn(r.columnId) const label = col?.type ? (COLUMN_LABELS[col.type] ?? col.type) : i18n.ts._dispatcher.columnFallback const targetId = columnTargetId(r.columnId) useSpotlightStore().highlight(targetId, { label: i18n.tsx._dispatcher.columnMoved({ actor, label }), }) } } else if (capId === 'column.updateSettings') { const r = result as { columnId?: string; applied?: string[] } | null if (r?.columnId) { const col = useDeckStore().getColumn(r.columnId) const label = col?.type ? (COLUMN_LABELS[col.type] ?? col.type) : i18n.ts._dispatcher.columnFallback const fields = r.applied?.join(', ') ?? i18n.ts._dispatcher.settingsFallback const targetId = columnTargetId(r.columnId) useSpotlightStore().highlight(targetId, { label: i18n.tsx._dispatcher.columnSettingsUpdated({ actor, label, fields, }), }) } } else if (capId === 'notifications.markRead') { // accountId 指定があれば per-account の navbar、なければ null (全アカウント) const accountId = typeof params?.accountId === 'string' ? params.accountId : null const targetId = navbarTargetId('notifications', accountId) useSpotlightStore().highlight(targetId, { label: i18n.tsx._dispatcher.notificationsRead({ actor }), }) } else if (capId === 'windows.open') { // 新規 or 既存 focus されたウィンドウを朱色 glow で枠を光らせる const r = result as { id?: string } | null const type = params?.type as string | undefined if (r?.id) { const label = type ? (WINDOW_LABELS[type] ?? type) : i18n.ts._dispatcher.windowFallback const targetId = windowTargetId(r.id) useSpotlightStore().highlight(targetId, { label: i18n.tsx._dispatcher.windowOpened({ actor, label }), }) } } else if (capId === 'windows.focus') { // 既存ウィンドウを最前面に → 同じ window:${id} を再点灯 const r = result as { id?: string } | null if (r?.id) { const win = useWindowsStore().windows.find((w) => w.id === r.id) const label = win ? (WINDOW_LABELS[win.type] ?? win.type) : i18n.ts._dispatcher.windowFallback const targetId = windowTargetId(r.id) useSpotlightStore().highlight(targetId, { label: i18n.tsx._dispatcher.windowFocused({ actor, label }), }) } } else if (capId === 'windows.close') { // 閉じた window の DOM は消えているので announce のみ useSpotlightStore().announce(i18n.tsx._dispatcher.windowClosed({ actor })) } else if (capId === 'windows.closeAll') { useSpotlightStore().announce( i18n.tsx._dispatcher.allWindowsClosed({ actor }), ) } else if (capId === 'notes.react') { // リアクション付与: 戻り値 { ok, noteId, reaction } から noteId と // reaction を取り、対応する note 本体を spotlight する。 const r = result as { noteId?: string; reaction?: string } | null if (r?.noteId) { useSpotlightStore().highlight(noteTargetId(r.noteId), { label: r.reaction ? i18n.tsx._dispatcher.noteReactedWith({ actor, reaction: r.reaction, }) : i18n.tsx._dispatcher.noteReacted({ actor }), }) } } else if (capId === 'notes.unreact') { const r = result as { noteId?: string } | null if (r?.noteId) { useSpotlightStore().highlight(noteTargetId(r.noteId), { label: i18n.tsx._dispatcher.noteUnreacted({ actor }), }) } } else if (capId === 'notes.pin') { const r = result as { noteId?: string } | null if (r?.noteId) { useSpotlightStore().highlight(noteTargetId(r.noteId), { label: i18n.tsx._dispatcher.notePinned({ actor }), }) } } else if (capId === 'notes.unpin') { const r = result as { noteId?: string } | null if (r?.noteId) { useSpotlightStore().highlight(noteTargetId(r.noteId), { label: i18n.tsx._dispatcher.noteUnpinned({ actor }), }) } } else if (capId === 'notes.create') { // 投稿成功: 戻り値 { id, ... } の id がそのまま新規 note id。 // Timeline reactivity で DOM に出れば光る (出てなければ noop)。 const r = result as { id?: string } | null if (r?.id) { useSpotlightStore().highlight(noteTargetId(r.id), { label: i18n.tsx._dispatcher.noteCreated({ actor }), }) } } else if (capId === 'notes.delete') { // 削除は対象 DOM が消えるので視覚 spotlight 無し。SR テキストのみ。 useSpotlightStore().announce(i18n.tsx._dispatcher.noteDeleted({ actor })) } else if (capId === 'account.switch') { // アクティブアカウント切替: navbar popup が開いていれば該当行が朱色 glow。 // 閉じていれば視覚効果は無いが SR で読み上げ。 const r = result as { id?: string } | null if (r?.id) { const acc = useAccountsStore().accounts.find((a) => a.id === r.id) const label = acc ? getAccountLabel(acc) : r.id useSpotlightStore().highlight(accountTargetId(r.id), { label: i18n.tsx._dispatcher.accountSwitched({ actor, label }), }) } } } /** * cap.requiresConfirmation を見て confirm モーダル options を組み立てる。 * - false / 未指定 → null (= 確認スキップ)。ただし opts.force (クロス * アカウント実行 #777) なら汎用モーダルへフォールバック * - true → label + 引数 JSON で汎用モーダル * - 関数 → 関数の戻り値をそのまま使う (null 戻りは個別スキップ) */ interface PreparedConfirmation { confirmOpts: ConfirmOptions /** 「次から確認しない」の記憶スコープ。null = 記憶しない (user / heartbeat / external / クロスアカウント) */ skipScope: string | null } /** * 確認ダイアログの内容を組み立てる (dispatcher と notecore の確認要求で共通)。 * null = この呼び出しでは確認しない (宣言なし / no-op / 記憶済み)。 */ async function prepareConfirmation( cap: Capability, params: Record | undefined, capCtx: CapabilityContext, ctx: DispatchContext, cross: { crossAccount: boolean crossAccountId: string | undefined force?: boolean note?: string }, ): Promise { const { crossAccount, crossAccountId } = cross const force = crossAccount || cross.force === true const confirmOpts = await buildConfirmOptions(cap, params, capCtx, { force, }) if (!confirmOpts) return null if (cross.note) { confirmOpts.message = confirmOpts.message ? `${cross.note}\n${confirmOpts.message}` : cross.note } // 汎用「今後確認しない」(#714): capability 固有の remember (vault の接続 // 単位の信頼) を持たない capability に、scope × capability 単位のスキップを // 適用する。scope は ai.chat / plugin 個体のみ — user (本人操作の confirm は // 削らない)・ai.heartbeat (無人実行)・external は confirmSkipScope が null を // 返し、常に確認される。 // クロスアカウント実行 (#777) には「今後確認しない」記憶を適用しない — // 同一アカウント操作への同意を別アカウントでの実行に波及させない。 const rawScope = cap.onConfirmRemember || cross.force ? null : confirmSkipScope(ctx.principal) if ( rawScope !== null && !crossAccount && isConfirmSkipped(rawScope, confirmSkipKey(cap.id, params)) ) { return null } const skipScope = crossAccount ? null : rawScope // クロスアカウント実行: どのアカウントとして実行するかを必ず明示する if (crossAccount && crossAccountId) { const acc = useAccountsStore().accounts.find((a) => a.id === crossAccountId) const line = i18n.tsx._dispatcher.executingAccount({ account: acc ? getAccountLabel(acc) : crossAccountId, }) confirmOpts.message = confirmOpts.message ? `${line}\n${confirmOpts.message}` : line } // 信頼マーカー (#720): これは NoteDeck 本体の権限確認である。プラグインの // Mk:confirm はこのフラグを立てられないので、システム確認になりすませない。 confirmOpts.trusted = true // 同一操作の dedup key (#720): 「今後確認しない」で許可したら、キューで // 待機している同じ scope×capability の確認も自動承認させる (#716 の // 「一度の同意を同一操作の待機分へ波及」)。skip 不可 scope とクロス // アカウント実行では付けない。 if (skipScope !== null) { confirmOpts.dedupKey = `${skipScope}:${cap.id}` } // 帰属表示 (#712 §3.3): 誰の要求かをダイアログ冒頭に必須表示する。 // 無人 HEARTBEAT のモーダルが本人のチャット確認と誤認されないよう、 // capability 側実装に任せず dispatcher が一律で注入する。 // 操作名はタイトル行が示すので主語 (actor) のみ (長文化を避ける)。 const actor = principalActorLabel(ctx.principal) if (actor) { confirmOpts.attribution = actor } // 編集の理由 (#1052): write 系 capability が受け取った reason を、承認前に // 「何を変えるか (diff)」と同じ画面で見せる。承認後は同じ文字列が編集履歴に // 記録されるので、ここで見せていないものが履歴に残ることはない。 const reason = typeof params?.reason === 'string' ? params.reason.trim() : '' if (reason) { confirmOpts.reason = reason } if (skipScope !== null && !confirmOpts.rememberLabel) { confirmOpts.rememberLabel = i18n.ts._dispatcher.rememberOperation } return { confirmOpts, skipScope } } /** * notecore の確認要求に同梱する内容を組み立てる (#1133)。要否の判定 * (宣言の confirm / 「次から確認しない」の記憶 / クロスアカウント) は notecore * 側で済んでいるので、ここは capability の実装が組む表示内容と、この引数で * 本当に確認が要るか (no-op なら要らない) だけを答える。実行はしない。 */ export async function previewConfirmation( capabilityId: string, params: Record | undefined, ctx: DispatchContext, opts: { crossAccount: boolean; destinationUntrusted?: boolean }, ): Promise { await whenPermissionsReady() const cap = getCapability(capabilityId) ?? listCapabilities().find((c) => sanitizeToolName(c.id) === capabilityId) if (!cap) return { needsConfirmation: false, allowRemember: false } const capCtx: CapabilityContext = { aiConfig: useAiConfig().config.value, principal: ctx.principal, ...(ctx.accountId ? { accountId: ctx.accountId } : {}), } const crossAccountId = opts.crossAccount ? pickAccountId(params?.accountId) : undefined const prepared = await prepareConfirmation(cap, params, capCtx, ctx, { crossAccount: opts.crossAccount, crossAccountId, }) if (!prepared) return { needsConfirmation: false, allowRemember: false } // 宛先が AI の読んだ他人の内容に由来する (#1103): 旗は足さず、一文だけ添える。 // 「次から確認しない」の対象外 if (opts.destinationUntrusted) { const line = i18n.ts._dispatcher.destinationUntrusted prepared.confirmOpts.message = prepared.confirmOpts.message ? `${prepared.confirmOpts.message}\n${line}` : line delete prepared.confirmOpts.rememberLabel } return { needsConfirmation: true, options: prepared.confirmOpts, allowRemember: !opts.crossAccount && !opts.destinationUntrusted && (prepared.skipScope !== null || Boolean(cap.onConfirmRemember)), } } /** * 確認要求で「次から確認しない」が ON のまま許可された (#1133)。dispatcher の * 同名処理と同じく、capability 固有の remember があればそれへ、無ければ * scope × capability 単位で permissions.json5 に記憶する。 */ export async function rememberConfirmation( capabilityId: string, params: Record | undefined, ctx: DispatchContext, ): Promise { const cap = getCapability(capabilityId) if (!cap) return if (cap.onConfirmRemember) { await cap.onConfirmRemember(params, { aiConfig: useAiConfig().config.value, principal: ctx.principal, ...(ctx.accountId ? { accountId: ctx.accountId } : {}), }) return } const scope = confirmSkipScope(ctx.principal) if (scope !== null) addConfirmSkip(scope, confirmSkipKey(cap.id, params)) } async function buildConfirmOptions( cap: Capability, params: Record | undefined, ctx: CapabilityContext, opts?: { force?: boolean }, ): Promise { if (!cap.requiresConfirmation && !opts?.force) return null if (typeof cap.requiresConfirmation === 'function') { return await cap.requiresConfirmation(params, ctx) } // boolean true → 汎用モーダル。signature.description は AI 向けの tool 説明で // 人間向けの確認には冗長 — 出さない (タイトル + 引数 JSON で足りる)。 const hasArgs = params && Object.keys(params).length > 0 return { title: i18n.tsx._dispatcher.confirmTitle({ label: cap.label }), message: '', // 引数 JSON は code block でシンタックスハイライト表示 ...(hasArgs ? { code: JSON.stringify(params, null, 2), codeLanguage: 'json', } : {}), okLabel: i18n.ts._common.run, cancelLabel: i18n.ts._common.cancel, type: 'danger', } } /** * required permission のうち disallow になっているものを返す。実行体 * principal と呼び出し元 (onBehalfOf) 全員の実効権限の AND で判定する * (#1099) — どれか 1 人でも持っていないキーは拒否。 */ function checkPermissions( required: readonly PermissionKey[], principal: Principal, onBehalfOf?: readonly Principal[], ): PermissionKey[] { if (required.length === 0) return [] const denied = new Set() for (const p of [principal, ...(onBehalfOf ?? [])]) { const resolved = resolveFor(p) // user principal はプロファイルを持たず常時許可 (#712 §3.3) if (resolved === null) continue for (const key of required) if (!resolved[key]) denied.add(key) } return required.filter((key) => denied.has(key)) }