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

Claude Code の mod(Claude Code の動きや画面を変えられるプラグイン) 入れたほかの mod に、どこへの通信・どのコマンド・どのファイルを許すかを mod ごとに決め、初めての操作は止めて画面で確認
mod は Claude Code と同じ権限で動き、ファイルの読み書きも外への通信もできる 公式も、信頼できる作者と marketplace のものだけ入れるよう書いている(公式の mods の概要のページ) ただ、入れたあとにその mod が裏で何をするかを縛る手段は、Claude Code 本体には無い
便利な道具を装って盗む手口は、実際に起きている
.env・SSH 鍵・各種トークンを読み、公開リポジトリに晒したClaude Code 2.1.287 以降が必要(claude --version で確認) 早期公開のときに CLAUDE_CODE_ENABLE_FUNCTION_HOOKS を設定していたら削除(2.1.287 以降は無視される、公式の mods の概要のページ)
/plugin marketplace add shumatsumonobu/claude-mods-bench
/plugin install mod-permissions@claude-mods-bench
/reload-plugins でも読み込める(公式の mods の概要のページ)新しく入れた mod があると、起動時に画面の右へ、その mod ができることの一覧を表示 例は、コード整形を装って裏で鍵を盗む偽の mod safe-format を入れたとき

その 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 に定義する従来のフック)では書けない次のことができる
$.http.fetch $.process.run $.fs.read などを捕まえ、next.origin で呼び出し元も分かる$ だけで、素の fetch も node:child_process も node:fs も使えない そのため $ の呼び出しを止めれば、通信・コマンド・プロジェクトの外への書き込みをすべて止められる(読み込み順に関係なく効く)$ の中で待つ時間は数えない $.process.run(['sleep', '0.25']) を繰り返し、ボタンが押されるまで止めておく実測(Claude Code 2.1.285 と 2.1.289)と、設計上の限界
.env を読まれても止まらず、読んだ中身を外へ出す手段(通信・プロジェクトの外への書き込み・コマンド・MCP)のほうを止める 例外として、.claude/ 配下と CLAUDE.md への書き込みは、次のセッションの設定や指示が変わるため確認node -e など) 通信も宛先ごとの許可なので、その宛先へは何を送っても通る、確認の欄にも赤字で注記/plugin で入れた mod は入れた順に読み込まれる(入れる順を変えて3回)ので、mod-permissions を先に入れておくと、あとから入れた mod が一覧に出る --plugin-dir で読み込んだ mod は、/plugin で入れた mod より先に読み込まれる(1回) 呼び出しを止めて確認するほうは、読み込み順に関係なく効くclaude -p)では確認できないため止める ── 事前に常に許可した組み合わせは通るhooks/register.ts 536 lines1import 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