Claude Media
Claude Codeのサブエージェントをhookで読み取り専用のDB分析役にする

Claude Codeのサブエージェントをhookで読み取り専用のDB分析役にする

サブエージェントのfrontmatterにPreToolUse hookを書き、Bashで流れるSQLのうち書き込み系だけを終了コード2で止める手順。公式のdb-readerを土台に、すり抜ける条件と二重化の考え方まで扱います。

データ分析用のサブエージェントに Bash を渡しつつ、SQLの書き込みだけを止めたい。tools フィールドは「Bashを許すか許さないか」の二択なので、この要望には届きません。Claude Codeの公式ドキュメントが示す答えは、サブエージェントの定義ファイルに PreToolUse hookを書き、Bashコマンドの中身を検査するスクリプトで INSERT や DROP を終了コード2で止める構成です。

ここでは、その db-reader を自分のプロジェクトに置く手順を順に追います。動作確認の方法、正規表現が止めるものと止めないもの、hookが働かなくなる条件、DB側の権限との二重化までを扱います。

読み取り専用DBサブエージェントは3つの部品でできている

構成は定義ファイル、検証スクリプト、実行権限の3点です。

部品置き場所の例役割
サブエージェント定義置き場所の例.claude/agents/db-reader.md役割tools: Bash と hooks を宣言する
検証スクリプト置き場所の例scripts/validate-readonly-query.sh役割標準入力のJSONからコマンドを取り出し、書き込み系の語があれば終了コード2を返す
実行権限置き場所の例スクリプト自体のファイルモード役割macOSとLinuxでは必須。無いとhookは失敗し、何も止めない

サブエージェントがBashを呼ぶたびに、Claude Codeは PreToolUse hookとして検証スクリプトを起動します。スクリプトには、ツール名とツール入力を含むJSONが標準入力で渡ります。Bashの場合、実行しようとしているコマンドは tool_input.command にあります。

定義ファイルを置く

公式の例をそのまま写すと、次の定義になります。hooks の中身は、settings.json のhookと同じ形式です。

---
name: db-reader
description: Execute read-only database queries. Use when analyzing data or generating reports.
tools: Bash
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/validate-readonly-query.sh"
---
 
You are a database analyst with read-only access. Execute SELECT queries to answer questions about the data.
 
When asked to analyze data:
1. Identify which tables contain the relevant data
2. Write efficient SELECT queries with appropriate filters
3. Present results clearly with context
 
You cannot modify data. If asked to INSERT, UPDATE, DELETE, or modify schema, explain that you only have read access.

本文のプロンプトにも「書き込みは断る」と書いてあります。公式はこれを前提に、hookを「それでも書き込もうとしたときのbackstop(最後の歯止め)」と位置づけています。プロンプトで抑え、hookで物理的に止める二段の作りです。

command の相対パス ./scripts/... は、hookが動くときの作業ディレクトリに左右されます。公式のhooksリファレンスは、スクリプトの参照に ${CLAUDE_PROJECT_DIR} を使う書き方を示しています。パスを確実にしたいときは、次のようにexec form(args を使う形)へ書き換えます。

hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "${CLAUDE_PROJECT_DIR}/scripts/validate-readonly-query.sh"
          args: []

検証スクリプトを置いて実行権限を付ける

公式の Database query validator の節にあるスクリプトは、次のとおりです。

#!/bin/bash
# Blocks SQL write operations, allows SELECT queries
 
# Read JSON input from stdin
INPUT=$(cat)
 
# Extract the command field from tool_input using jq
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
 
if [ -z "$COMMAND" ]; then
  exit 0
fi
 
# Block write operations (case-insensitive)
if echo "$COMMAND" | grep -iE '\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|REPLACE|MERGE)\b' > /dev/null; then
  echo "Blocked: Write operations not allowed. Use SELECT queries only." >&2
  exit 2
fi
 
exit 0

jq を使うので、hookを動かす環境に jq が入っている必要があります。保存したら実行権限を付けます。

chmod +x ./scripts/validate-readonly-query.sh

公式は権限の付け忘れについて、フックが「ブロックする代わりに失敗する」と明記しています。失敗したhookは既定では操作を止めません。つまり付け忘れると、検査ゼロのまま全SQLが通ります。この点は後の節で対策します。

同じ公式ドキュメントの別の箇所(Conditional rules with hooks)には、正規表現が INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE の7語で、空コマンドのチェックも無い短い版があります。サンプルの節にある版は REPLACE と MERGE を足した9語で、-z による空判定が入っています。どちらかを土台にするなら、語数の多い後者が出発点になります。

実際にブロックされるかを確かめる

スクリプトだけなら、JSONを手で流し込めば単体で試せます。

echo '{"tool_input":{"command":"psql -c \"SELECT count(*) FROM orders\""}}' \
  | ./scripts/validate-readonly-query.sh; echo "exit=$?"
 
echo '{"tool_input":{"command":"psql -c \"update orders set a=1\""}}' \
  | ./scripts/validate-readonly-query.sh; echo "exit=$?"

1本目は exit=0、2本目はstderrに Blocked: Write operations not allowed. Use SELECT queries only. が出て exit=2 になります。小文字の update も止まるのは、grep -i で大文字小文字を区別しないためです。

次に、Claude Code上で確かめます。db-reader に UPDATE 文を実行させると、スクリプトが終了コード2を返してコマンドがブロックされ、サブエージェントはstderrのメッセージを受け取ります。メッセージが定義の「書き込み系は断る」と食い違わないかも、ここで見ておく価値があります。

この正規表現が止めるものと止めないもの

スクリプトは語単位(\b)の一致で判定します。SQLを構文解析しているわけではないので、挙動はこの仕組みから読み取れます。

コマンドの例結果理由
SELECT count(*) FROM orders結果通る理由禁止語が無い
update orders set a=1結果止まる理由大文字小文字を区別しない
SELECT * FROM audit WHERE action = 'delete'結果止まる理由文字列リテラルの中の delete にも一致する
SELECT created_at FROM t結果通る理由created_at は単語として CREATE と一致しない
GRANT ALL ON t TO u結果通る理由GRANT は9語に含まれない
COPY t TO PROGRAM '...'結果通る理由COPY も含まれない

上の3行目のように、読み取りなのに止まる誤検知は起こります。止めすぎは安全側の失敗なので、許容できることが多い部分です。逆に、6行目までのように語リストに無い文は素通りします。公式のリストは「書き込み系の代表語」であり、DBが持つ全ての変更手段を網羅するものではありません。ストアドプロシージャの呼び出しや、権限を変えるステートメントなど、自分の環境で使われる危険な操作は、リストに足して使うことになります。

もうひとつ、このスクリプトはBashコマンドの文字列全体を見ます。psql に渡すSQLに限らず、同じコマンド行に含まれる語がすべて対象です。サブエージェントに Bash を渡す以上、rm など別のコマンドも通ります。検査が見るのは「禁止語の有無」だけで、SQL以外の操作を制限する仕組みではありません。ファイル操作まで絞りたいなら、tools で Bash 以外のツールを減らす、permissions の deny を併用するといった別の手段が要ります。権限ルール全体の設計はサブエージェント権限ルール設計で扱っています。

hookが働かなくなる条件

「定義を置いたのに止まらない」ときの原因は、公式ドキュメントから次の4つに絞れます。

  1. プロジェクトの .claude/agents/ にある定義は、そのフォルダを信頼する(workspace trust dialogを承認する)まで、frontmatterのhookが動きません。信頼前でもサブエージェント自体は動くため、気づきにくい状態です。親フォルダを信頼しても足りず、-p セッションも信頼済みとは数えません。~/.claude/agents/ のユーザー定義と --agents で渡した定義は、この手順が要りません。v2.1.218より前は、信頼していないフォルダでも動いていました。
  2. プラグインが配布するサブエージェントは、セキュリティ上の理由で hooks mcpServers permissionMode が無視されます。定義ファイルを .claude/agents/ か ~/.claude/agents/ にコピーして使います。
  3. スクリプトが起動できない、実行権限が無い、jq が無い、といった失敗は「非ブロッキングエラー」です。トランスクリプトにhook errorの表示は出ますが、操作は進みます。
  4. スクリプトが時間切れになったときも、PreToolUse のcommand型hookはツール呼び出しを止めません。通常の権限フローに戻ります。

原因の切り分けには、デバッグログが使えます。claude --debug-file <path> で起動するとhookの実行結果がログに残り、v2.1.296以降は、tool eventのcommand hookごとに「終了ステータスと所要時間」を示す行が付きます。failed to start や timed out が出ていれば、スクリプト側の失敗です。

3と4の対策が、onFailure: block です。Claude Code v2.1.295以降の設定で、hookが失敗したら操作を止めます。frontmatterにも次のように書けます。

hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "${CLAUDE_PROJECT_DIR}/scripts/validate-readonly-query.sh"
          args: []
          onFailure: block

終了コードの扱いも押さえておきます。ブロックは終了コード2だけです。exit 1 はUnix慣習では失敗ですが、標準出力に有効なJSONが無ければ非ブロッキング扱いで、操作が通ります。スクリプトの途中で別の理由により1を返して終わると、検査したつもりでも操作は通ります。ポリシーを守らせるスクリプトでは、止めたい分岐に明示的に exit 2 を書きます。onFailure の挙動と設定の詳細はonFailureで失敗時に操作を止める設定にまとめています。

終了コード2とJSON出力のどちらで止めるか

公式の例は終了コード2とstderrのメッセージで止めます。PreToolUse には、標準出力にJSONを返して permissionDecision を deny にする方法もあります。

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database writes are not allowed"
  }
}

JSON方式は終了コード0で返します。allow(確認なしで通す)、ask(ユーザーに確認する)、deny(止める)、defer を選べるので、たとえば「書き込み系はユーザー確認に回す」といった中間の設計ができます。「書き込みは一切不可」なら終了コード2のほうが単純です。なお、終了コード2で終わったhookは、JSONで allow を返してもブロックを覆せません。

settings.jsonに置くと何が変わるか

同じ検査を settings.json に置くこともできます。違いは効く範囲です。

置き場所効く範囲向く場面
サブエージェントのfrontmatter効く範囲そのサブエージェントが動いている間だけ向く場面分析役だけを縛りたい
settings.json効く範囲メイン会話とすべてのサブエージェント向く場面プロジェクト全体で書き込みSQLを禁じたい

settings.json の PreToolUse hookは、サブエージェントのツール呼び出しにもメイン会話と同じように発火します。サブエージェントが起動・終了したときに別の処理を挟みたいなら、SubagentStart と SubagentStop にmatcherでエージェント名を指定できます。詳しくはSubagentStart/SubagentStop hookを参照してください。hook全般の仕様はHooks完全ガイドにあります。

DB側の権限と二重にする

ここまでの検査は、Claude Codeが実行するコマンドの文字列に対するものです。DBの接続情報そのものが書き込み可能なら、文字列検査をすり抜けた1本がそのまま実行されます。実運用では、接続先のDBユーザーを読み取り専用ロールにして、hookは「そもそも書き込みを試みさせない」ためのもうひとつの層にする構成が、より堅くなります。

どちらの層が先に効くかは、設計の考え方で変わります。DB側は「通ったら必ず止まる」確実さを、hookは「実行前に理由つきで止められる」速さと説明力を持ちます。hookが返すstderrのメッセージは、サブエージェントに渡ります。

MCPサーバー経由でDBに繋ぐ場合は、Bashを経由しないため、この検査は働きません。MCP側の読み取り専用設定は、データベースMCPサーバーの読み取り専用設定の比較に5製品分があります。

まとめ

読み取り専用のDB分析役は、定義ファイルに tools: Bash と PreToolUse hookを書き、標準入力のJSONから tool_input.command を取り出して書き込み系の語で終了コード2を返すスクリプトで作れます。押さえる点は4つです。

  • 実行権限(chmod +x)と jq の用意。無いとhookは失敗し、何も止めない
  • プロジェクトの定義はworkspace trustの承認後に動く。プラグイン配布のサブエージェントでは hooks が無視される
  • 語リストに無い操作(GRANT や COPY)は通る。必要な語は自分で足す
  • 失敗時に止めたいなら onFailure: block、DB側の読み取り専用ロールとの二重化も検討する

検査の強さは、正規表現の語リストと、DBに渡している権限の両方で決まります。

この記事を共有:XはてブLinkedIn