AIエージェントの権限設計
本番DBを読ませたいが、書かせたくない。読み取り専用のSQLラッパーを実際に書き、拒否ルールとhookの順番を公式仕様で確かめた。
PR:この記事には、当サイトが参加する(予定の)アフィリエイトプログラムの紹介枠(VPS)があります。紹介する基準は料金とリージョンだけです。
エージェントに本番を見せたい場面は多くあります。ログを読ませたい、DB の件数を数えさせたい、といった場面です。ただし、書き込みまでは許したくありません。この記事では、Claude Code の権限ルールと hook の評価順を公式ドキュメントで確かめたうえで、読み取り専用のラッパーを実際に書いて試します。
試した環境は、編集部の検証環境(Linux、Claude Code 2.1.301、Python 3.13.5、SQLite 3.46.1、2026-10-16)です。
評価の順番を知っておく
公式ドキュメントの要点です(出典:https://code.claude.com/docs/en/permissions 、2026-10-16確認)。
- 権限ルールは deny → ask → allow の順に評価され、最初に一致したものが結果になります。ルールの細かさは順番に影響しません。
- 広い deny(例
Bash(aws *))は、狭い allow(例Bash(aws s3 ls))よりも優先されます。allow で deny に例外を作ることはできません。 - PreToolUse hook が allow を返しても、一致する deny と ask のルールは迂回できません。
- 逆に、hook が
exit 2で止めた呼び出しは、allow ルールより先に止まります。 - user の設定で allow、project の設定で deny なら、deny が勝ちます。
つまり、止める仕組みは何重にも重ねられます。一方で、許可を足して止める仕組みを外すことはできません。設計は「deny を広めに、allow を狭く」から始めます。
hook の実装と、素通りした例は「Claude Code hooks実践」にあります。
生の curl や psql を allow しない
本番の読み取りを毎回承認するのは手間なので、つい Bash(curl *) や Bash(psql *) を allow したくなります。しかし同じエンドポイントで書き込みも通ってしまいます。編集部では、読み取りしかできないラッパーを1本作り、そのフルパスだけを allow に登録しています。
読み取り専用ラッパーを書いて試す
SELECT か WITH で始まらない文、書き込みの語を含む文、複数の文を拒否します。接続そのものも読み取り専用モードで開き、二重に守ります。
#!/usr/bin/env python3
# /usr/local/bin/ro-sql(デモは SQLite。本番では読み取り専用ロールで接続する)
import re, sys, sqlite3
sql = sys.argv[1].strip().rstrip(';')
WRITE = r'\b(insert|update|delete|drop|alter|create|replace|truncate|attach|pragma|vacuum|grant)\b'
if ';' in sql: sys.exit("reject: 複数文")
if not re.match(r'(?is)^\s*(select|with)\b', sql): sys.exit("reject: SELECT/WITH以外")
if re.search(WRITE, sql, re.I): sys.exit("reject: 書き込み語")
db = sqlite3.connect('file:demo.db?mode=ro', uri=True)
for row in db.execute(sql): print(row)
| 入力 | 結果 |
|---|---|
SELECT id, selected_at FROM jobs | 実行された |
select * from jobs where deleted_flag=0 | 実行された |
WITH x AS (SELECT 1) SELECT * FROM x | 実行された |
SELECT 1; DELETE FROM jobs | reject: 複数文 |
DELETE FROM jobs | reject: SELECT/WITH以外 |
select * from jobs where 'delete' = 'x' | reject: 書き込み語 |
語の境界(\b)で判定しているので、selected_at や deleted_flag のような列名では誤って拒否しません。文字列リテラルの中の 'delete' は拒否されます。安全側に倒れる誤判定なので、そのままにしています。
設定には、ラッパーのフルパスを書きます。
{
"permissions": {
"allow": ["Bash(/usr/local/bin/ro-sql *)"],
"deny": ["Bash(psql *)", "Bash(curl *)"]
}
}
ラッパーをシェルの関数や変数で包むと、許可ルールの前方一致に当たらなくなります。呼び出しは、パスをそのまま書く形にそろえます。
「確認のつもり」の probe で消した
外部サービスの API で、どのメソッドに対応しているかを確かめようとして、for m in GET POST PUT DELETE を回したことがあります。実在するデータを1件、削除してしまいました。
いまは、probe 用のスクリプトが GET・HEAD・OPTIONS しか受け付けないようにしています。調べるときは、先に OpenAPI の定義を読みます。書き込みを試す必要があるなら、ダミーのデータを作ってから、それを対象にします。
直すなら「足す」方向で
auto mode の分類器は、同じ修復でも方向で扱いを変えました。ツールを追加でインストールする修復は通り、設定行を削除する修復は「セキュリティを弱める変更」として止まりました。遠回りに見えても、足す方向の直し方を先に探します。
無人実行には人間のゲートを残す
鍵と信頼できない入力を同じプロセスに置かない理由は「AIエージェントをVPSで24時間動かす」に書きました。ここでは、その先の話をします。OS の鍵保管庫も、シェルから読めるのであれば、この脅威に対する守りになりません。本番への書き込みは、対話の端末から人が起動したときだけにしています。人間のゲートは、パスフレーズを人が手で打つ形で作りました。
本番に書き込めたことで実際に起きた静かな事故は、「デプロイとマイグレーションの事故集」にまとめました。
MCP サーバーのツールを、使うものだけ許可する書き方は「MCPサーバーの選び方」にまとめました。
まとめ
- 権限は deny → ask → allow。allow で deny に穴は開けられない。
- 生の curl や psql は allow しない。読み取り専用のラッパーのフルパスだけを許可する。
- probe は GET・HEAD・OPTIONS に限る。書き込みはダミーのデータで試す。
- 鍵と信頼できない入力を、同じ無人プロセスに置かない。