エージェントツールの権限と監査
ルートをエージェントツールとして公開したあと(エージェントインターフェースを参照)、エージェントに何を許すかを決め、実際に何をしたかを残す仕組みが 3 つあります。
エージェントツールの権限と監査
ルートをエージェントツールとして公開したあと(エージェントインターフェースを参照)、エージェントに何を許すかを決め、実際に何をしたかを残す仕組みが 3 つあります。
| 仕組み | 決めること | 節 |
|---|---|---|
| トークンのスコープ | bearer トークンから見えて呼べるツール | トークンとスコープ |
| 承認ゲート | 実行前に人の承認を待つ呼び出し | 承認が必要なツール |
| 監査ログ | すべての呼び出しと拒否の記録先 | 監査ログ |
トークンのスコープは必須で、スコープのないトークンからはツールが 1 つも見えません。以下の「呼び出し主体(principal)」は、呼び出しを行う ID のことです。トークンやセッションの持ち主のユーザー、またはユーザーを持たないトークンならサービスがこれにあたります。承認ゲートと監査ログは任意で、どちらか一方だけでも使えます。
トークンとスコープ
既存の ['*'] トークンでは、エージェントツールは 1 つも使えません。 ツールのスコープとして扱われる ability は tool: と tools: だけで、それ以外の ability は、ApiToken の既定値である ['*'] も含め、どれにも一致しません。これは意図した設計です。アプリで最初の .agent() ルートを宣言した途端に、エージェントツールができる前に発行したすべてのトークンがエージェント向けのツール全体を使えるようになる、という事故を防ぐためです。エージェント向けのツールへのアクセスは、明示的に付与するか、まったく付与しないかのどちらかです。
スコープの形は次の 4 つだけです。
| スコープ | 付与する範囲 |
|---|---|
tool:posts.store |
そのツール 1 つだけ |
tools:read |
最終的な readOnlyHint が true のツールすべて |
tools:posts.* |
posts.… という名前のツールすべて(ドットも一致の条件に含まれるので、posts という名前のツールは含まれません) |
tools:* |
すべてのツール |
スコープは足し合わせる方式で、拒否を表す書き方はありません。トークンのスコープに含まれないツールは、呼び出しを拒否されるだけでなく、tools/list にも表示されません。付与されていないツールまで一覧に出すと、読み取り専用のエージェントに書き込み系ツールの全体像を教えてしまうからです。
トークンを発行する
bunx guren token:issue --name blog-reader --user 42 --tools 'tools:read' --expires 30d
✔ Issued token "blog-reader" for user 42.
Token (shown once — it is stored hashed and cannot be recovered)
1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Expires 2026-09-29T09:00:00.000Z
Abilities tools:read
Granted tools
read: posts.index, posts.show
write: (none)
書き込みも行うエージェントには、このトークンのスコープを広げずに、別のトークンを発行してください。
bunx guren token:issue --name blog-writer --user 42 --tools 'posts.store' --expires 30d
--tools には短縮形も使えます。名前だけを書くと tool:<name>、posts.* は tools:posts.*、read は tools:read、* は tools:* として扱われます。
| オプション | 意味 |
|---|---|
--name |
必須。あとで失効させるときに、このトークンを見分けるための名前です。 |
--user |
必須。トークンで認証されるユーザーの ID です。 |
--tools |
必須。カンマ区切りのスコープです。 |
--read-only |
付与するツールを読み取り専用のものに限ります。 |
--expires |
30d・12h・45m のように指定します。省略すると期限のないトークンになります。 |
--allow-unmatched |
今はどのツールにも一致しないスコープも受け入れます。 |
--yes |
tools:* を指定するときに必要です。 |
--json |
発行したトークンを JSON で出力します。警告も含まれます。 |
このコマンドは、警告で済ませるより拒否することを選びます。資格情報を扱うコマンドでのタイプミスは、画面を見ているうちに直すのがいちばん手間がかからないからです。
- 今どのツールにも一致しないスコープは拒否されます。 そうしたスコープは、タイプミスか潜在的な付与(latent grant)のどちらかです。潜在的な付与とは、一致するツールが追加された途端に、誰も同意していないのに有効になってしまう、保存済みのパターンのことです。
--allow-unmatchedを付ければ受け入れられますが、その場合はこの危険について警告が出ます。 tools:*には--yesが必要です。 アプリが今公開しているツールと、今後追加されるツールのすべてを付与します。破壊的な操作をするツールも含まれます。--read-onlyでは、具体的なツール名で保存されます。 付与する範囲は発行時に展開され、パターンではなくtool:<name>のエントリとして保存されます。スコープの文法に「posts.*のうち読み取り専用のものだけ」という書き方がないからです。そのため安全側に倒れていて(fail-closed)、あとからposts.で始まる書き込み系のツールが追加されても、保存済みのエントリには含まれません。また--read-onlyを付けた場合、一致しないスコープは--allow-unmatchedを付けても拒否されます。あとから何かが付与されることは、そもそもありえないからです。
発行時には次の 2 つの警告が出ることがあります。どちらも警告だけで、発行は拒否しません。
- 期限のないトークンは、誰かが手で失効させるまで有効なままです
- 読み取り系と書き込み系のツールを両方含むトークンは、これまでに知られているインジェクションの事例と同じ構成です。攻撃者の影響を受けたコンテンツを読み、しかも書き戻せるエージェントは、そのコンテンツに操られるおそれがあります。できれば 2 つのトークンに分けてください
OAuth クライアントが要求するスコープ
このスコープの文法は MCP 仕様ではなくアプリケーション独自のものなので、一般的なクライアントには推測できず、スコープを何も送ってきません。そこで guren cloudflare:build --mcp-oauth で生成した worker は、仕様がクライアントに読むよう求めている 2 か所で、受け付けるスコープを提示します。401 チャレンジの WWW-Authenticate に含める scope と、Protected Resource Metadata(RFC 9728)です。どちらにも tools:read を載せます。
ここにあえて大まかな読み取り用のスコープを載せているのには理由があります。仕様に従うクライアントはこのフィールドに並ぶスコープをすべて要求しますし、このフィールドには基本的な機能に必要な最小限のスコープを載せることになっています。ここに tools:* を並べると、すべてのクライアントがツール全体へのアクセスを要求してしまいます。範囲の広い tools:* は、authorization server metadata(RFC 8414)のほうに載せてあります。こちらはクライアントがスコープを 選ぶ ときに読む文書ではないので、サーバーが何を受け付けるかを確認したい人向けの情報です。
これらを無視してスコープを送ってこないクライアントでも、既定値を当てはめた同意画面が表示されるので、空のページになることはありません。書き込み系のツールまで範囲を広げるにはそのツールの正確な名前が必要ですが、read だけを付与された状態では名前を知る方法がありません。付与範囲の外のツールは tools/list に表示されず、guren_preflight でも拒否されるからです。名前は bunx guren tool:list で確認し、tool:<name> を指定して認可をやり直してください。
承認が必要なツール
操作によっては、エージェントに求められたというだけで実行させたくないものがあります。ルートに印を付けると、呼び出しは実行されず、人への申請に変わります。
router
.delete('/posts/:id', { params: PostIdParamSchema }, [PostController, 'destroy'])
.name('posts.destroy')
.agent({ description: 'Delete a post.', approval: 'required' })
最初の呼び出しは拒否されます。何も実行されずに承認待ち(pending)の申請が作られ、承認者に通知が届き、エージェントには申請 ID が返ります。
{
"status": "pending",
"requestId": "8f0c…",
"tool": "posts.destroy",
"requestedAt": "2026-09-01T12:00:00.000Z",
"expiresAt": "2026-09-01T13:00:00.000Z",
"executed": false,
"pollWith": "guren_approval_status"
}
人がその申請を承認したあと、エージェントが同じ引数で同じ呼び出しをもう一度行うと、今度は 1 回だけ通ります。再度の呼び出しは呼び出し側が行います。アプリケーション自身がホストしている永続エージェントなら、専用の永続台帳をもとに自動で再実行されます(永続エージェントを参照)。
キューを設定する
既定のストアはありません。承認待ちの申請をどこに保存するかは、アプリケーション側で決めます。監査ログの出力先に既定値がないのと同じ理由です。このエンドポイントは Workers や Lambda でも動くので、フレームワークが黙ってプロセスのメモリに保存してしまうと、次の isolate が知らない申請を承認してしまいかねません。
import { AgentApprovalRequested } from '@guren/core'
import { mcpPlugin } from '@guren/plugin-mcp'
mcpPlugin({
approvals: {
store: new DrizzleApprovalStore(db),
notify: (request) => notifications.sendToMany(admins, new AgentApprovalRequested(request)),
ttlMs: 60 * 60 * 1000,
},
})
DrizzleApprovalStore はフレームワークには含まれません。examples/agents にある実装をコピーして使ってください。store は AgentApprovalStore を実装します。
| メソッド | 役割 |
|---|---|
create(request) |
新しい承認待ちの申請を保存する |
find(id) |
その ID の申請を返す。なければ null |
findMatch({ tool, fingerprint, principalKey }) |
この呼び出しに一致する未消費の申請を返す。状態は問わず、複数あれば最新のもの |
consume(id) |
承認を消費し、この呼び出しが承認を使えたかどうかを返す |
実装では、次の 2 つを必ず守ってください。
consumeは compare-and-set にします。consumedAtがまだ空のときだけ書き込み、すでに値が入っていたらfalseを返します。同時に来た 2 つの呼び出しは同じ承認済みのレコードを見つけるので、無条件に書き込む実装では両方に承認を渡してしまいます。findMatchでは、期限でも状態でも絞り込みません。 その判定はフレームワークが行います。ストアでも判定すると同じ規則の写しが 2 つできてしまい、しかも危険な方向に壊れます。比較を 1 つ書き忘れただけで、先月の承認が今日の呼び出しを通してしまうからです。
notify には申請が渡されるだけで、誰に知らせるかはアプリケーションが決めます。フレームワークには承認者の一覧が見えないので、宛先を選べません。よくある使い方のために AgentApprovalRequested を用意してあります。これをサブクラス化しても、まったく別のものを送っても構いません。申請は notify を呼ぶ前に保存され、notify の完了を待ってから応答することもありません。メールの送信経路が落ちていても、失われるのは通知 1 通だけで、申請も呼び出しも残ります。送信の失敗は申請 ID と一緒にログに出ます。レスポンスを返した時点でまだ送信中の通知も、途中で打ち切られません。Workers ではその Promise をリクエストの waitUntil に渡すので、Webhook や SMTP のやり取りも最後まで完了します。
申請の承認や却下はアプリケーション側で行い、保存先もアプリケーションが持ちます。status を 'approved' か 'rejected' にし、resolvedAt と resolvedBy を書き込みます。フレームワークに approve() はありません。承認は、フレームワークからは見えない画面で人が行う操作だからです。
ゲートが強制する規則
- 承認は引数に結び付いています。
posts.destroy {id: 5}を承認しても、{id: 9}は許可されません。キーの順序やネストの順序は一致の判定に影響しませんが、型は影響します。{id: 5}と{id: '5'}は別の呼び出しです。判定には生の引数を正規化した SHA-256 を使い、保存されるのはそのハッシュと、秘密の値を伏せた引数だけです。そのため、キューが秘密情報のもう 1 つの保存場所になることはありません。 - 承認は 1 回限りで、期限があります。 一度通ったら、次の呼び出しにはまた新しい申請が必要です。
expiresAtを過ぎたレコードは何も許可しません。 - 承認は呼び出し元に結び付いています。 引数が同じでも、別の呼び出し主体に対する承認では通りません。
- 承認はディスパッチの前に消費されます。 そのあとで呼び出しが失敗しても、消費済みのままです。破壊的な操作が 1 回の承認で 2 回実行されるより、もう一度承認してもらうほうが安全だからです。
- 承認待ちの呼び出しを繰り返しても、申請は増えません。 同じ申請 ID が返り、承認者への通知も 2 回目は送られません。
- 却下された呼び出しは、再び申請されません。 拒否の応答には
"status": "rejected"が入るので、エージェントはポーリングを続けて待つ意味がある状態と区別できます。レコードの期限が切れたあとは、新しい申請として扱われます。
guren_approval_status
キューを設定すると、エンドポイントにはエンドポイント自身のツールがもう 1 つ追加されます。拒否の応答に入っていた requestId を渡してください。
{ "name": "guren_approval_status", "arguments": { "requestId": "8f0c…" } }
{
"requestId": "8f0c…",
"status": "approved",
"tool": "posts.destroy",
"requestedAt": "2026-09-01T12:00:00.000Z",
"expiresAt": "2026-09-01T13:00:00.000Z",
"resolvedAt": "2026-09-01T12:04:11.000Z",
"resolvedBy": "ops@example.com",
"executed": false
}
状態を読むだけでは何も実行されません。"approved" は「今もう一度呼んでよい」という意味です。guren_preflight と同じくトークンの読み取りの上限を消費するので、短い間隔でポーリングし続けると制限にかかります。
状態を読める申請は、自分が作ったものに限られます。別の呼び出し主体の申請 ID を渡すと、存在しない ID とまったく同じ答えが返ります。そうしないと、同僚がどんな承認を待っているかを調べ上げる手段になってしまうからです。呼び出し元に見せない区別は、監査ログのほうに残ります。状態の確認も、guren_approval_status として記録される通常の呼び出しです。
bunx guren check は、mcpPlugin({ … }) の呼び出しを読み取れて、その中に approvals がない場合、approval: 'required' を宣言したルートを失敗にします。キューがなければ、そのツールは守られているのではなく、単に呼び出せないだけだからです。
監査ログ
ツールの呼び出しも拒否も、すべてフレームワークのイベントとして発行されます。すでにイベントをどこかに転送しているなら、同じ転送先に送れます。
| イベント | 発生するとき | 含まれる情報 |
|---|---|---|
AgentToolInvoked |
呼び出しがアプリケーションに届いた | principal・tool・arguments・status・durationMs・surface |
AgentToolDenied |
HTTP リクエストを作る前に Guren が拒否した | principal・tool・arguments・reason・surface |
reason は 'auth'・'scope'・'approval'・'rate-limit' のいずれかで、それぞれリクエストの前に行う検査を指します。ポリシーによる拒否は、この中に含まれません。 ポリシーはディスパッチされたリクエストの中で評価されるので、ステータスが 403 の AgentToolInvoked として届きます。拒否にはステータスも所要時間もありません。何も実行されていないからです。
principal は呼び出しを行った主体で、{ kind: 'user' | 'service', id } の形です。誰も認証していなければ null になります。surface は呼び出しの経路で、mcp(このエンドポイント)、dev-mcp、cli(guren tool:call)、webmcp(ブラウザ内のセッション)、durable(アプリがホストするエージェント)、in-process(@guren/plugin-ai)のどれかです。
guren_preflight の呼び出しも、ほかの呼び出しと同じように tool: 'guren_preflight' として記録されます。エージェントが「自分に何が許されているか」を探ったという事実は、監査ログにこそ残したい情報です。確認された側のツールについては、何も呼び出されていないので記録は残りません。拒否された場合も同じで、guren_preflight の AgentToolDenied として記録されます。確認された側のツール名で記録すると、予行演習が拒否されたのか、書き込み系ツールの本当の呼び出しが拒否されたのかを区別できなくなるからです。どのツールが確認されたかは、記録の引数(tool)に残ります。
// app/Providers/EventServiceProvider.ts など、リスナーを登録している場所
import { AgentToolInvoked, AgentToolDenied, createFacades } from '@guren/core'
const { Events, Log } = createFacades(app.container)
Events.on(AgentToolInvoked, (event) => {
Log.info('agent tool invoked', {
tool: event.tool,
principal: event.principal?.id,
status: event.status,
durationMs: event.durationMs,
arguments: event.arguments,
})
})
Events.on(AgentToolDenied, (event) => {
Log.warn('agent tool denied', { tool: event.tool, reason: event.reason })
})
イベントを発行するには、イベントマネージャがバインドされている必要があります。mcpPlugin() と一緒に EventServiceProvider(またはアプリ独自のイベントプロバイダ)を登録してください。登録していないと、プラグインは起動時に警告を出し、イベントを 1 つも発行しません。
監査ログを書き出す
イベントは、記録する仕組みがあってもなくても発行されます。記録を残しておきたい場合は、プラグインに出力先(シンク)を設定します。
import { mcpPlugin } from '@guren/plugin-mcp'
createApp({
providers: [
EventServiceProvider,
mcpPlugin({
audit: { file: 'storage/logs/agent-audit.log', days: 30 },
}),
],
})
指定したパスと同じディレクトリにある agent-audit-YYYY-MM-DD.log に、1 行 1 件の JSON レコードを追記し、ファイルは日ごとに切り替えます。切り替えのときに、days より古いファイルは削除されます(既定は 14 日)。file はファイルシステムがそのまま解決するので、絶対パスか、プロセスのカレントディレクトリからの相対パスを指定してください。アプリケーションのルートを基準には解決しません。
ファイル以外に書き出したいときは、関数を渡します。
mcpPlugin({
audit: {
sink: async (record) => {
await auditStream.write(record)
},
},
})
シンクが例外を投げると警告が出ますが、記録しようとしていたツールの呼び出し自体は失敗しません。レスポンスを返した時点でまだ書き込み中のシンクも、途中で打ち切られません。Workers ではその Promise をリクエストの waitUntil に渡すので、D1 への書き込みも最後まで完了します。
シンクを設定すると、bunx guren tool:call の呼び出しも記録されるようになります。このコマンドはアプリケーションを起動するので、アプリで設定した監査ログをそのまま見つけて書き込みます。呼び出し 1 回につき 1 件、surface: 'cli' として記録され、引数は同じ .agent({ redact }) のリストでマスクされます。記録は MCP の記録と同じファイルに並び、別のファイルは作られません。これには実際的な意味があります。ターミナルからの呼び出しは --as で指定したユーザーとして実行され、検証される資格情報が 1 つもありません。監査ログで残しておきたいのは、まさにこうした書き込みです。
bunx guren tool:call --preflight は、MCP 経由の予行演習とまったく同じように guren_preflight として記録され、確認された側のツール名は引数に残ります。ハンドラーは実行されていないので、確認した側のツール名で記録すると、最後まで実行された呼び出しと区別できなくなるからです。アプリケーションの @guren/core が予行演習に対応していない古いバージョンの場合は、呼び出しが実際に実行されます。その場合はコマンドが警告を出し、記録には実際に実行されたツールの名前が残ります。
tool:call で記録されるのは呼び出し(invocation)だけで、拒否(denial)は記録されません。4 つの拒否理由はどれも、アダプターがリクエストを組み立てる前に行うチェックですが、このコマンドはそのどれも行わないからです。トークンを持たず、アプリに直接ディスパッチします。アプリケーションが返した 401 や 403 はレスポンスなので、ほかの経路(surface)と同じく、そのステータスの呼び出しとして記録されます。呼び出し主体は --as で指定したユーザーで、指定がなければ null です。abilities は含まれません。abilities はトークンに付くものですが、ここにはトークンがないからです。
シンクを設定していないアプリケーションでは、tool:call の呼び出しも記録されません。呼び出し自体はこれまでどおり実行され、結果も表示されます。
シンクの設定をあえて任意にしているのには理由があります。 このエンドポイントは、書き込めるファイルシステムがない Workers や、ファイルシステムが一時的な Lambda でも動きます。フレームワークが勝手にファイルへの追記を始めると、設定は同じに見えるのに、デプロイ先によっては記録が黙って欠けていく監査ログになってしまいます。監査ログは、記録が漏れなく残っているかが分かって初めて役に立つので、Guren では書き出し先を明示してもらう設計にしています。
監査ログを読む
# 直近 50 件
bunx guren tool:log
# 追従表示。日付が変わってファイルが切り替わっても追い続けます
bunx guren tool:log --tail
# 拒否だけ、特定のツールだけ、直近 2 時間だけ
bunx guren tool:log --denied
bunx guren tool:log --tool posts.store --since 2h -n 200
# 1 行 1 レコードの生データ。パイプ処理向け
bunx guren tool:log --json | jq 'select(.status >= 400)'
| オプション | 意味 |
|---|---|
--file <path> |
監査ログのベースパス(既定は storage/logs/agent-audit.log) |
--tail・-f |
新しいレコードが届くたびに表示する |
--tool <name> |
指定したツールだけ |
--surface <s> |
mcp・dev-mcp・cli・webmcp・durable・in-process のいずれかだけ |
--denied |
拒否だけ |
--since <duration> |
30m・2h・7d などの期間より新しいレコードだけ |
-n <count> |
表示する件数(既定は 50) |
--app <dir> |
ベースパスを解決するときの基準になるアプリケーションのルート |
--json |
1 行 1 レコードの生データ |
tool:log はアプリケーションを起動しません。記録しているアプリが起動できなくなっていても、監査ログは読めなければ困るからです。日ごとに分かれたファイルを新しいものから順に読むので、日付をまたいで -n を指定しても正しく件数がそろいます。-n はフィルタをかけたあとに適用されるので、--denied -n 50 は「直近 50 件の中の拒否」ではなく「直近の拒否 50 件」になります。
監査ログが 1 件も見つからないときは、空の一覧ではなく、追加すべき設定の行を表示します。ここで空の一覧を出すと「どのエージェントもこのアプリに触れていない」と読めてしまいますが、シンクを設定していないだけなら、それはまったく逆の結論になってしまいます。
秘密の値を伏せる(redaction)
event.arguments は、イベントを作る前にマスクされます。マスクの対象は、2 つのリストを合わせたものです。1 つは、どのアプリでも設定なしで有効になる機密性の高いキー断片の組み込みリスト(password・passphrase・secret・token・apikey・authorization・credential・cookie・session・privatekey・pwd・jwt)、もう 1 つはルート自身の redact メタデータです。
router
.post('/integrations', { body: CreateIntegrationSchema }, [IntegrationController, 'store'])
.name('integrations.store')
.agent({
description: 'Connect an external integration.',
redact: ['webhookUrl'],
})
一致の判定は、あえて大まかにしてあり、マスクしすぎる方向に倒れます。
- キーの名前を小文字にして区切り文字を取り除いたものに、断片が含まれていれば一致します。
apiKey・api_key・x-api-keyは、どれもapikey1 つで対象になります - 自分で宣言したエントリにも同じ判定が使われるので、
redact: ['id']と書くとuserIdもマスクされます - 値の形より先に、キーで判定します。
tokenという名前のキーの下にあるネストしたオブジェクトは、中身をたどらずにまるごとマスクされます
マスクした値は [REDACTED] に置き換えます。値をたどる処理は必ず終わるようにしてあり、循環参照は [Circular]、極端に深いペイロードは [Truncated] になります。この処理は「何かが起きた」ことを記録している最中に動き、ルート自身の検証より前に行われた拒否も対象になるからです。
関連
- エージェントインターフェース: これらの仕組みをかけるツールの宣言、テスト、公開
- API トークン: トークンのストアと、トークンが持つアビリティ
- イベント: 監査イベントのリスナーの登録方法
- 永続エージェント: 承認された呼び出しを自分の台帳から再試行する、アプリがホストするエージェント