SLOPSHOPPER

mod-permissions

入れたほかの mod の通信・コマンド・ファイル操作を、実行前に止めて確認し、mod ごとに許すかを決める

newpanebandpromptprocess
A shopper browsing a rack in a slop shop
README

mod-permissions

Claude Code の mod(Claude Code の動きや画面を変えられるプラグイン) 入れたほかの mod に、どこへの通信・どのコマンド・どのファイルを許すかを mod ごとに決め、初めての操作は止めて画面で確認

課題

mod は Claude Code と同じ権限で動き、ファイルの読み書きも外への通信もできる 公式も、信頼できる作者と marketplace のものだけ入れるよう書いている(公式の mods の概要のページ) ただ、入れたあとにその mod が裏で何をするかを縛る手段は、Claude Code 本体には無い

便利な道具を装って盗む手口は、実際に起きている

  • Nx の s1ngularity(2025-08) ── 乗っ取られたビルドツールが、入れた人の .env・SSH 鍵・各種トークンを読み、公開リポジトリに晒した
  • postmark-mcp(2025-09) ── メール送信の MCP サーバーを装い、送るメールすべてに攻撃者宛ての BCC を付けた

導入

Claude Code 2.1.287 以降が必要(claude --version で確認) 早期公開のときに CLAUDE_CODE_ENABLE_FUNCTION_HOOKS を設定していたら削除(2.1.287 以降は無視される、公式の mods の概要のページ)

  1. このリポジトリを marketplace(プラグインの配布元)として登録
   /plugin marketplace add shumatsumonobu/claude-mods-bench
  1. mod-permissions を入れる
   /plugin install mod-permissions@claude-mods-bench
  1. Claude Code を起動し直す ── 次の起動から有効、初回に作業フォルダの信頼を求められたら承認 開いたままのセッションなら、/reload-plugins でも読み込める(公式の mods の概要のページ)

動作

新しく入れた mod があると、起動時に画面の右へ、その mod ができることの一覧を表示 例は、コード整形を装って裏で鍵を盗む偽の mod safe-format を入れたとき

新しく入れた mod ができることの一覧

その mod が鍵や環境変数を読む・外へ送る、といった操作をしようとすると、呼び出しを止めてプロンプトの上で確認 1(Allow once 今回だけ)・2(Always allow 常に)・3(Deny 拒否)か、クリックで選ぶ 答えるまで、その呼び出しは止まったまま

下は、safe-format がファイルを編集したあと、裏で鍵を読もうとしたのを止めた画面

プロジェクトの外の読み取りを止めて確認する画面

環境変数の読み取り・外への送信・設定ファイルの書き換えも、同じように止めて確認 設定ファイル(.claude/settings.json)を書き換えられると次のセッションの設定が変わり、mod-permissions を外されることもある(safe-format は中身を丸ごと上書きする)

選んだ結果はログ(.claude/mod-permissions.log)に記録

2026-10-05T08:03:59.328Z safe-format read outside the project C:/Users/example/.ssh/id_rsa allowed (once)
2026-10-05T08:04:09.279Z safe-format read an environment variable AWS_SECRET_ACCESS_KEY allowed (once)
2026-10-05T08:04:31.310Z safe-format make a network request https://collector.example/upload?key=none allowed (once)
2026-10-05T08:04:32.722Z safe-format change Claude Code settings or instructions C:/dev/mods-bench/.claude/settings.json allowed (once)

プロンプトの上の1行には、止めた数と許可した数を表示

プロンプトの上に出る、止めた数と許可した数

常に許可と拒否は、mod・操作の種類・相手の組み合わせごとに $.store(mod 専用の保存領域)へ保存 次のセッションでも、同じ組み合わせは確認せずに同じ答えを使う

設定

/config の When not allowed · mod-permissions から変更

値動き
ask止めて画面で確認(既定)
log only止めずにログへ記録
block確認せずに止める

Seconds to wait · mod-permissions で、答えを待つ秒数を変更(既定60秒、過ぎたら止める)

settings.json に直接書く場合

{
  "pluginConfigs": {
    "mod-permissions": {
      "options": {
        "mode": "ask",
        "waitSeconds": 60
      }
    }
  }
}

設計上の理由

mod は Claude Code の中で動くため、shell hook(settings.json に定義する従来のフック)では書けない次のことができる

  • ほかの mod の呼び出しが見える ── shell hook に届くのは Claude のツール呼び出しやセッションの節目だけで、mod が裏でする通信・コマンド・ファイル操作は届かない mod なら、ほかの mod の $.http.fetch $.process.run $.fs.read などを捕まえ、next.origin で呼び出し元も分かる
  • 外とやり取りする手段をすべて押さえられる ── mod が外とやり取りする手段は $ だけで、素の fetch も node:child_process も node:fs も使えない そのため $ の呼び出しを止めれば、通信・コマンド・プロジェクトの外への書き込みをすべて止められる(読み込み順に関係なく効く)
  • 答えるまで呼び出しを止めておける ── フックが自分のコードに使える時間は10秒だが、$ の中で待つ時間は数えない $.process.run(['sleep', '0.25']) を繰り返し、ボタンが押されるまで止めておく

仕様上の注意

実測(Claude Code 2.1.285 と 2.1.289)と、設計上の限界

  • 止めるのは外へ出す操作で、プロジェクト内のふつうの読み取りは見ない リポジトリ内の .env を読まれても止まらず、読んだ中身を外へ出す手段(通信・プロジェクトの外への書き込み・コマンド・MCP)のほうを止める 例外として、.claude/ 配下と CLAUDE.md への書き込みは、次のセッションの設定や指示が変わるため確認
  • 常に許可すると、その組み合わせは中身を問わず通る コマンドを常に許可すると、引数を問わずそのコマンドでできることがすべて通る(node -e など) 通信も宛先ごとの許可なので、その宛先へは何を送っても通る、確認の欄にも赤字で注記
  • 確認の欄はプロンプトの上に出し、この場所は入れた mod 全部で共有する 外側の mod が、ほかの mod の分を並べずに自分の表示だけを返すと、内側の表示は消える(公式の interface のページ) そういう作りの mod と一緒に入れると、確認の欄が出ないまま、待つ秒数が過ぎて止まることがある
  • 入れたときの一覧は画面の右に出すため、端末の横幅が144文字以上必要 135文字の端末では出ず、広げると出た(2026-10-05、1回) 確認の欄と、止めた数・許可した数はプロンプトの上に出るので、幅に関係なく表示
  • 入れたときの一覧に出せるのは、mod-permissions より後に読み込まれた mod だけ /plugin で入れた mod は入れた順に読み込まれる(入れる順を変えて3回)ので、mod-permissions を先に入れておくと、あとから入れた mod が一覧に出る --plugin-dir で読み込んだ mod は、/plugin で入れた mod より先に読み込まれる(1回) 呼び出しを止めて確認するほうは、読み込み順に関係なく効く
  • シンボリックリンクは追わない ── プロジェクト内に外を指すリンクを置かれると、プロジェクトの中か外かを誤って判定しうる
  • 画面の無いセッション(claude -p)では確認できないため止める ── 事前に常に許可した組み合わせは通る
  • 見張るのは利用者が自分で入れた mod だけ ── Claude Code に同梱されたプラグインや、管理者が入れたプラグインはそのまま通す
Source 1 files
hooks/register.ts 536 lines
1import type { On, PluginOptions } from 'claude-code'
2
3/**
4 * 自分の名前、plugin.json の name とそろえる
5 * 自分の `$` 呼び出しも自分のフックを通るので、この名前の呼び出しは見張らずに通す
6 */
7const SELF = 'mod-permissions'
8
9/** 入れたときの一覧を画面の右に開くときの ID */
10const CARD_PANE = 'mod-permissions-cards'
11/** 答えを待つ間、`sleep` で止める1回の秒数 */
12const POLL_SECONDS = '0.25'
13/** ログに残す行数の上限 */
14const LOG_LIMIT = 1000
15
16/** 確認する操作の種類 */
17type Kind = 'net' | 'run' | 'write' | 'config' | 'read' | 'mcp' | 'env' | 'settings' | 'prompt'
18/** 確認の欄での答え ── 今回だけ許可、常に許可、拒否 */
19type Choice = 'once' | 'always' | 'deny'
20
21/** 何をしようとしているかの動詞句、確認の欄とログに出す(`${plugin} wants to ...`) */
22const KIND_LABEL: Record<Kind, string> = {
23  net: 'make a network request',
24  run: 'run a command',
25  write: 'write outside the project',
26  config: 'change Claude Code settings or instructions',
27  read: 'read outside the project',
28  mcp: 'call an MCP tool',
29  env: 'read an environment variable',
30  settings: 'read Claude Code settings',
31  prompt: 'submit a prompt on your behalf',
32}
33
34/**
35 * 常に許可すると、中身を問わず何でも通ってしまう種類
36 * 確認の欄に赤字で注記する
37 */
38const BLANK_CHECK: Partial<Record<Kind, string>> = {
39  run: 'this command runs with any arguments. Allowing node or bash allows anything they can do',
40  net: 'this plugin may send anything to this host',
41}
42
43/**
44 * mod が使う `$` を、できることの言葉に直す(入れたときの一覧に出す)
45 * ここに載せたものは、下で必ず確認の対象にする ── 一覧に出すだけで止めないと、守れないものを守れるように見せてしまう
46 */
47const CALL_LABEL: Record<string, string> = {
48  'http.fetch': 'make network requests',
49  'process.run': 'run commands',
50  'process.spawn': 'run commands',
51  'fs.write': 'write files',
52  'fs.read': 'read files',
53  'mcp.call': 'call MCP tools (incl. sending email)',
54  'env.get': 'read environment variables (may hold secrets)',
55  'settings.read': 'read Claude Code settings',
56  'prompt.submit': 'submit prompts on your behalf',
57}
58
59/** mod が受け取るイベントを、見て書き換えられるものの言葉に直す(入れたときの一覧に出す) */
60const EVENT_LABEL: Record<string, string> = {
61  '*': 'see and rewrite every event',
62  'tool.call': 'see and rewrite tool calls and results',
63  'prompt.submit': 'see and rewrite submitted prompts',
64  'prompt.context': 'see and rewrite CLAUDE.md and other instructions',
65  'turn.step': 'see and rewrite model responses',
66}
67
68/** 確認している1件 ── だれが、何を、どこに、の内容と、ボタンで決まった答え */
69type Ask = { plugin: string; kind: Kind; target: string; detail: string; choice: Choice | null }
70/** 入れたときの一覧に出す mod 1つ ── 名前、版、新しく入ったか、できること */
71type Card = { name: string; version: string; isNew: boolean; abilities: string[] }
72
73/** 許可していない操作をどうするか(`/config` の When not allowed) */
74let mode = 'ask'
75/** 答えを待つ時間(ミリ秒、`/config` の Seconds to wait) */
76let waitMs = 60_000
77/** 画面のあるセッションか、画面が無いと確認できない */
78let interactive = true
79/** プロジェクトの場所 */
80let root = ''
81/** いま確認の欄に出している1件、無ければ null */
82let held: Ask | null = null
83/** 入れたときの一覧に出す mod */
84let cards: Card[] = []
85/** 一覧に出した mod のできること、一覧を閉じたときに見たものとして保存する */
86const shown: Record<string, string[]> = {}
87/** プロンプトの上の1行に出す、許可した数と止めた数 */
88const counts = { allowed: 0, denied: 0 }
89/**
90 * 答えが無いまま時間切れになったプラグイン
91 * そのセッションの間は確認せずに止める(席を外している人を、何度も待たせない)
92 */
93const silenced = new Set<string>()
94
95/** ログへの追記を1本ずつ順に流す、読んで書く間に別の追記が割り込むと片方が消えるため */
96let queue: Promise<unknown> = Promise.resolve()
97
98/** パスの区切りを `/` にそろえ、末尾の `/` を取る */
99export function norm(path: string): string {
100  return path.replace(/\\/g, '/').replace(/\/+$/, '')
101}
102
103/** パスのフォルダ部分 */
104export function dirOf(path: string): string {
105  const p = norm(path)
106  const i = p.lastIndexOf('/')
107  return i > 0 ? p.slice(0, i) : p
108}
109
110/** プロジェクトの中のパスか、Windows に合わせて大文字と小文字を区別しない */
111export function inside(path: string, base: string): boolean {
112  if (base === '') return false
113  const p = norm(path).toLowerCase()
114  const b = norm(base).toLowerCase()
115  return p === b || p.startsWith(b + '/')
116}
117
118/**
119 * プロジェクトの中の、設定や指示のファイルか(`.claude/` の下と、`CLAUDE.md` `AGENTS.md` `CLAUDE.local.md`)
120 * ここを書き換えられると、次のセッションの設定や指示が変わる
121 * たとえば `.claude/settings.json` に mod-permissions を無効にする設定を書かれると、以降この mod は何も止めなくなる
122 * そのため、プロジェクトの中でも、ここへの書き込みだけは確認する
123 */
124export function isConfig(path: string, base: string): boolean {
125  if (!inside(path, base)) return false
126  const rel = norm(path).toLowerCase().slice(norm(base).length)
127  return rel.includes('/.claude/') || /\/(claude|agents)\.md$/.test(rel) || /\/claude\.local\.md$/.test(rel)
128}
129
130/** URL のホスト名、URL として読めなければそのまま */
131export function hostOf(url: string): string {
132  try {
133    return new URL(url).host
134  } catch {
135    return url
136  }
137}
138
139/** 実行するコマンドの名前(引数の1つ目の、パスを除いた部分) */
140export function commandOf(argv: unknown): string {
141  const first = Array.isArray(argv) ? String(argv[0] ?? '') : String(argv ?? '')
142  return norm(first).split('/').pop() ?? first
143}
144
145/**
146 * この呼び出しを見張るか、見張るなら呼び出し元の名前、見張らないなら null
147 * 見張るのは、利用者が自分で入れた mod(tier が user)だけ
148 * 自分の呼び出しと、Claude Code に同梱されたプラグインや管理者が入れたプラグイン(builtin・prepend・append)は通す
149 * 後者を止めると、指示ファイルの読み込みのような本体の動きまで壊れるため
150 */
151export function policed(next: any): string | null {
152  const o = next?.origin
153  const plugin = typeof o?.plugin === 'string' ? o.plugin : ''
154  if (plugin === '' || plugin === SELF) return null
155  if (o?.tier !== 'user') return null
156  return plugin
157}
158
159/** mod が使う `$` と受け取るイベントから、入れたときの一覧に並べるできることを作る、重なりは1つにまとめる */
160export function abilitiesOf(uses: { calls?: string[]; events?: string[] } | undefined): string[] {
161  const out = [
162    ...(uses?.events ?? []).map((e) => EVENT_LABEL[e]),
163    ...(uses?.calls ?? []).map((c) => CALL_LABEL[c]),
164  ].filter((s): s is string => typeof s === 'string')
165  return [...new Set(out)]
166}
167
168/** 前に見た版から増えたできることだけを返す、初めて見る mod なら全部 */
169export function addedAbilities(now: string[], before: string[] | undefined): string[] {
170  if (before === undefined) return now
171  return now.filter((a) => !before.includes(a))
172}
173
174/**
175 * プロジェクトの場所
176 * ファイルを直すと mod はその場で読み直され、そのときは session.start が来ないので、空なら取りに行く
177 */
178async function rootOf($: any): Promise<string> {
179  if (root === '') root = norm(String(await $.session.root()))
180  return root
181}
182
183/** ログ(`.claude/mod-permissions.log`)に1行足す、上限を超えた古い行は捨てる */
184async function log($: any, line: string) {
185  const file = `${await rootOf($)}/.claude/mod-permissions.log`
186  const stamp = new Date().toISOString()
187  const run = async () => {
188    const prev = (await $.fs.exists(file)) ? String(await $.fs.read(file)) : ''
189    const lines = [...prev.split('\n').filter((l) => l !== ''), `${stamp} ${line}`]
190    await $.fs.write(file, lines.slice(-LOG_LIMIT).join('\n') + '\n')
191  }
192  await (queue = queue.then(run, run))
193}
194
195/** 止めたときに、止められた mod と Claude に届く文 */
196function refusal(plugin: string, kind: Kind, target: string, why: string): string {
197  return `mod-permissions: blocked ${plugin} from trying to ${KIND_LABEL[kind]} (${target}). ${why}`
198}
199
200/**
201 * 許すかを決める、通すなら null、止めるなら理由を返す
202 * 常に許可と拒否は `$.store` に保存し、次のセッションでも確認しない
203 * 画面の無いセッションでは確認できないので止める
204 * 確認するときは呼び出しを止めたまま待ち、プロンプトの上の欄のボタンで決める
205 * 待つのは `$` の中(`sleep`)なので、フックの持ち時間には数えられない
206 */
207export async function gate($: any, plugin: string, next: any, kind: Kind, target: string, detail: string): Promise<string | null> {
208  const key = `${plugin} ${kind} ${target}`
209  const grants: Record<string, string> = ((await $.store.get('grants')) as Record<string, string> | undefined) ?? {}
210
211  const settle = async (allow: boolean, note: string) => {
212    if (allow) counts.allowed++
213    else counts.denied++
214    $.ui.invalidate('ui.render')
215    await log($, `${plugin} ${KIND_LABEL[kind]} ${detail} ${note}`).catch(() => {})
216  }
217
218  if (grants[key] === 'always') {
219    await settle(true, 'allowed (rule)')
220    return null
221  }
222  if (grants[key] === 'never') {
223    await settle(false, 'blocked (rule)')
224    return refusal(plugin, kind, target, 'denied before')
225  }
226  if (mode === 'log only') {
227    await settle(true, 'logged only')
228    return null
229  }
230  if (silenced.has(plugin)) {
231    await settle(false, 'blocked (silenced this session)')
232    return refusal(plugin, kind, target, 'no answer earlier, blocked for the rest of this session')
233  }
234  if (mode === 'block' || !interactive) {
235    await settle(false, 'blocked (cannot ask)')
236    return refusal(plugin, kind, target, mode === 'block' ? 'settings block anything not allowed' : 'cannot ask in a screenless session')
237  }
238
239  // 1件ずつ確認する、先に確認しているものがあれば、答えが出るまで待つ
240  while (held !== null) {
241    if (next.signal?.aborted) return refusal(plugin, kind, target, 'turn interrupted')
242    await $.process.run(['sleep', POLL_SECONDS], { timeoutMs: 5000 })
243  }
244  const mine: Ask = { plugin, kind, target, detail, choice: null }
245  held = mine
246
247  // 確認の欄はプロンプトの上に出す、画面の右は端末が狭いと開かず、何も見えないまま待つことになるため
248  let choice: Choice | 'timeout' | 'interrupted' | 'error'
249  try {
250    $.ui.invalidate('ui.render')
251    const startedAt = await $.clock.now()
252    while (mine.choice === null) {
253      if (next.signal?.aborted) break
254      if ((await $.clock.now()) - startedAt > waitMs) break
255      await $.process.run(['sleep', POLL_SECONDS], { timeoutMs: 5000 })
256    }
257    choice = mine.choice ?? (next.signal?.aborted ? 'interrupted' : 'timeout')
258  } catch {
259    choice = 'error'
260  } finally {
261    if (held === mine) held = null
262    $.ui.invalidate('ui.render')
263  }
264
265  if (choice === 'always') {
266    await $.store.set('grants', { ...grants, [key]: 'always' })
267  }
268  if (choice === 'once' || choice === 'always') {
269    await settle(true, choice === 'always' ? 'allowed (always)' : 'allowed (once)')
270    return null
271  }
272  if (choice === 'deny') {
273    await $.store.set('grants', { ...grants, [key]: 'never' })
274  }
275  if (choice === 'timeout') silenced.add(plugin)
276  const why = {
277    deny: 'denied by user',
278    timeout: `no answer within ${Math.round(waitMs / 1000)}s`,
279    interrupted: 'turn interrupted',
280    error: 'error while asking',
281  }[choice]
282  await settle(false, why)
283  return refusal(plugin, kind, target, why)
284}
285
286/** ほかの mod の通信(`$.http.fetch`)を、宛先のホストごとに確認する */
287export async function onFetch($: any, e: any, next: any) {
288  const plugin = policed(next)
289  if (plugin === null) return next(e)
290  const url = String(e.url ?? '')
291  const why = await gate($, plugin, next, 'net', hostOf(url), url.slice(0, 200))
292  return why === null ? next(e) : { deny: why }
293}
294
295/** ほかの mod のコマンド実行(`$.process.run`)を、コマンドの名前ごとに確認する */
296export async function onRun($: any, e: any, next: any) {
297  const plugin = policed(next)
298  if (plugin === null) return next(e)
299  const argv = Array.isArray(e.argv) ? e.argv.map(String) : [String(e.argv ?? '')]
300  const why = await gate($, plugin, next, 'run', commandOf(argv), argv.join(' ').slice(0, 200))
301  return why === null ? next(e) : { deny: why }
302}
303
304/** ほかの mod の、出力を流し続けるコマンド実行(`$.process.spawn`)を確認する */
305export async function* onSpawn($: any, e: any, next: any) {
306  const plugin = policed(next)
307  if (plugin === null) return yield* next(e)
308  const argv = Array.isArray(e.argv) ? e.argv.map(String) : [String(e.argv ?? '')]
309  const why = await gate($, plugin, next, 'run', commandOf(argv), argv.join(' ').slice(0, 200))
310  if (why !== null) return { deny: why }
311  return yield* next(e)
312}
313
314/** ほかの mod のファイルの書き込み(`$.fs.write`)を確認する、プロジェクトの中は設定や指示のファイルだけ */
315export async function onWrite($: any, e: any, next: any) {
316  const plugin = policed(next)
317  if (plugin === null) return next(e)
318  const path = String(e.path ?? '')
319  const base = await rootOf($)
320  // プロジェクトの中でも、設定や指示のファイルは確認する、それ以外のプロジェクトの中の書き込みは通す
321  if (inside(path, base)) {
322    if (!isConfig(path, base)) return next(e)
323    const why = await gate($, plugin, next, 'config', norm(path).slice(norm(base).length + 1), norm(path))
324    return why === null ? next(e) : { deny: why }
325  }
326  const why = await gate($, plugin, next, 'write', dirOf(path), norm(path))
327  return why === null ? next(e) : { deny: why }
328}
329
330/** ほかの mod の、プロジェクトの外のファイルの読み取り(`$.fs.read`)を、フォルダごとに確認する */
331export async function onRead($: any, e: any, next: any) {
332  const plugin = policed(next)
333  if (plugin === null) return next(e)
334  const path = String(e.path ?? '')
335  if (inside(path, await rootOf($))) return next(e)
336  const why = await gate($, plugin, next, 'read', dirOf(path), norm(path))
337  return why === null ? next(e) : { deny: why }
338}
339
340/** ほかの mod の MCP ツールの呼び出し(`$.mcp.call`)を、サーバーとツールごとに確認する */
341export async function onMcp($: any, e: any, next: any) {
342  const plugin = policed(next)
343  if (plugin === null) return next(e)
344  const target = `${String(e.server ?? '')}/${String(e.tool ?? '')}`
345  const why = await gate($, plugin, next, 'mcp', target, target)
346  return why === null ? next(e) : { deny: why }
347}
348
349/** ほかの mod の環境変数の読み取り(`$.env.get`)を、変数の名前ごとに確認する */
350export async function onEnv($: any, e: any, next: any) {
351  const plugin = policed(next)
352  if (plugin === null) return next(e)
353  const name = String(e.name ?? '')
354  const why = await gate($, plugin, next, 'env', name, name)
355  return why === null ? next(e) : { deny: why }
356}
357
358/** ほかの mod の、Claude Code の設定の読み取り(`$.settings.read`)を確認する */
359export async function onSettings($: any, e: any, next: any) {
360  const plugin = policed(next)
361  if (plugin === null) return next(e)
362  const source = String(e.source ?? '')
363  const why = await gate($, plugin, next, 'settings', source, source)
364  return why === null ? next(e) : { deny: why }
365}
366
367/** ほかの mod が、利用者の代わりにプロンプトを送ること(`$.prompt.submit`)を確認する */
368export async function onPrompt($: any, e: any, next: any) {
369  const plugin = policed(next)
370  if (plugin === null) return next(e)
371  const text = String(e.text ?? '')
372  const why = await gate($, plugin, next, 'prompt', plugin, text.slice(0, 200))
373  return why === null ? next(e) : { deny: why }
374}
375
376/** 自分より後に読み込まれる mod のできることを控え、前に見た版から増えたものを入れたときの一覧に出す */
377export async function onRegister($: any, e: any, next: any) {
378  if (e.name !== SELF && e.tier === 'user') {
379    const abilities = abilitiesOf(e.uses)
380    const seen: Record<string, string[]> = ((await $.store.get('abilities')) as Record<string, string[]> | undefined) ?? {}
381    const added = addedAbilities(abilities, seen[e.name])
382    if (added.length > 0) {
383      cards.push({ name: String(e.name), version: String(e.version ?? ''), isNew: seen[e.name] === undefined, abilities: added })
384      shown[e.name] = abilities
385    }
386  }
387  return next(e)
388}
389
390/** セッションの始まり ── 画面があるかを見て、入れたときの一覧に出すものがあれば画面の右に開く */
391export async function onStart($: any, e: any, next: any) {
392  interactive = e.isInteractive !== false
393  silenced.clear()
394  root = norm(String(e.cwd ?? (await $.session.root())))
395  if (interactive && cards.length > 0) {
396    await $.ui.open({ id: CARD_PANE, title: 'Mod permissions', rows: 4 + cards.length * 3 })
397    $.ui.invalidate('ui.render')
398  }
399  return next(e)
400}
401
402/** 最初のプロンプトを送ったら入れたときの一覧を閉じ、見せたできることを見たものとして保存する */
403export async function onTurn($: any, e: any, next: any) {
404  if (cards.length > 0 && interactive) {
405    const seen: Record<string, string[]> = ((await $.store.get('abilities')) as Record<string, string[]> | undefined) ?? {}
406    await $.store.set('abilities', { ...seen, ...shown })
407    cards = []
408    try {
409      await $.ui.close({ id: CARD_PANE })
410    } catch {
411      // 開いていなかった
412    }
413  }
414  return next(e)
415}
416
417/** 確認の欄、ボタンは描いた時点の1件にだけ答える */
418export function drawAsk(t: any, ask: Ask) {
419  const { Box, Text, Button } = t
420  const answer = (choice: Choice) => () => {
421    if (ask.choice === null) ask.choice = choice
422  }
423  const caution = BLANK_CHECK[ask.kind]
424  return Box({
425    flexDirection: 'column',
426    borderStyle: 'round',
427    borderColor: 'yellow',
428    paddingX: 1,
429    children: [
430      Text({ key: 'title', bold: true, color: 'yellow', children: `${ask.plugin} wants to ${KIND_LABEL[ask.kind]}` }),
431      Text({ key: 'target', children: [Text({ dimColor: true, children: 'Target  ' }), Text({ bold: true, children: ask.target })], wrap: 'truncate-end' }),
432      Text({ key: 'detail', children: [Text({ dimColor: true, children: 'Detail  ' }), Text({ children: ask.detail })], wrap: 'truncate-end' }),
433      Box({
434        key: 'buttons',
435        marginTop: 1,
436        gap: 2,
437        children: [
438          Button({ key: 'once', label: 'Allow once', hotkey: '1', plain: true, onPress: answer('once') }),
439          Button({ key: 'always', label: 'Always allow', hotkey: '2', plain: true, onPress: answer('always') }),
440          Button({ key: 'deny', label: 'Deny', hotkey: '3', plain: true, autoFocus: true, onPress: answer('deny') }),
441        ],
442      }),
443      caution ? Text({ key: 'caution', color: 'red', children: `Always allow: ${caution}` }) : null,
444      Text({ key: 'hint', dimColor: true, children: 'Press 1, 2 or 3, or click a button. This call is paused until you answer.' }),
445    ],
446  })
447}
448
449/** 入れたときの一覧 ── 新しく入った mod と、更新で増えたできること */
450export function drawCards(t: any, list: Card[]) {
451  const { Box, Text } = t
452  return Box({
453    flexDirection: 'column',
454    borderStyle: 'round',
455    borderColor: 'cyan',
456    paddingX: 1,
457    children: [
458      Text({ key: 'title', bold: true, color: 'cyan', children: 'Mod permissions · new plugins / newly added access' }),
459      ...list.map((c, i) =>
460        Box({
461          key: `c${i}`,
462          flexDirection: 'column',
463          marginTop: 1,
464          children: [
465            Text({ key: 'name', bold: true, children: `${c.name} ${c.version} ${c.isNew ? '(new)' : '(added)'}` }),
466            ...c.abilities.map((a, j) => Text({ key: `a${j}`, ...(c.isNew ? {} : { color: 'magenta' }), children: `  · ${a}` })),
467          ],
468        }),
469      ),
470      Box({ key: 'hint', marginTop: 1, children: [Text({ dimColor: true, children: "You'll be asked the first time each is used. Closes when you send your next prompt." })] }),
471    ],
472  })
473}
474
475/** プロンプトの上の1行 ── 止めた数と許可した数、まだ何も確認していなければ出さない */
476export function drawBand(t: any, c: { allowed: number; denied: number }) {
477  if (c.allowed === 0 && c.denied === 0) return null
478  const { Box, Text } = t
479  return Box({
480    paddingX: 1,
481    children: [
482      Text({ key: 'name', bold: true, color: 'cyan', children: 'Mod permissions · ' }),
483      Text({ key: 'denied', ...(c.denied > 0 ? { color: 'red' } : {}), children: `blocked ${c.denied}` }),
484      Text({ key: 'sep', dimColor: true, children: ' · ' }),
485      Text({ key: 'allowed', children: `allowed ${c.allowed}` }),
486    ],
487  })
488}
489
490/** 画面の右に、入れたときの一覧を描く */
491export function onPane($: any, e: any, next: any) {
492  if (e.requestId === CARD_PANE && cards.length > 0) return drawCards($.ui.resolve(e), cards)
493  return next(e)
494}
495
496/**
497 * プロンプトの上の1行を描く ── 確認している最中はその欄、それ以外は止めた数と許可した数
498 * 確認している最中は、ほかの mod のボタンとキーが重ならないよう、確認の欄だけを出す
499 * それ以外は、ほかの mod の表示を消さないよう、自分の1行の下に並べる
500 */
501export async function onBand($: any, e: any, next: any) {
502  const t = $.ui.resolve(e)
503  if (held !== null) return drawAsk(t, held)
504  const ours = drawBand(t, counts)
505  const theirs = await next(e)
506  if (ours === null) return theirs
507  if (!theirs) return ours
508  return t.Box({ flexDirection: 'column', children: [ours, theirs] })
509}
510
511/**
512 * mod の入口 ── 入れたほかの mod が外とやり取りしようとしたら、mod ごとに許すかを決める
513 * mod は `$` を通さずに外とやり取りできない(素の fetch は無く、node:child_process と node:fs は import できない)
514 * 確認するもの ── 通信、コマンド実行、プロジェクトの外の読み書き、プロジェクトの中の設定や指示のファイルの書き換え、MCP の呼び出し、環境変数の読み取り、設定の読み取り、利用者の代わりのプロンプト送信
515 * 確認しないもの ── プロジェクトの中のふつうの読み書き(外へ出す手段を押さえているので、読んでも外へは出せない)、`$.model.*` などモデルへの問い合わせ
516 * 見張るのは利用者が入れた mod だけで、同梱や管理者のプラグインは通す
517 */
518export function register(on: On, options?: PluginOptions) {
519  mode = String(options?.mode ?? 'ask')
520  waitMs = Number(options?.waitSeconds ?? 60) * 1000
521  on('http.fetch', onFetch)
522  on('process.run', onRun)
523  on('process.spawn', onSpawn)
524  on('fs.write', onWrite)
525  on('fs.read', onRead)
526  on('mcp.call', onMcp)
527  on('env.get', onEnv)
528  on('settings.read', onSettings)
529  on('prompt.submit', onPrompt)
530  on('plugin.register', onRegister)
531  on('session.start', onStart)
532  on('turn.start', onTurn)
533  on('ui.render', { component: 'Pane' }, onPane)
534  on('ui.render', { component: 'AbovePrompt' }, onBand)
535}
536