AI

PreToolUseフックで代替手段まで返す

  • Claude Code
  • Security

Claude Code の permissions.deny はブロックするだけで、なぜ駄目なのかを返せません。受け取った側は代替手段を自分で探すことになり、そのぶんトークンを使います。Bash 向けの deny をすべて PreToolUse フックへ移し、ルールごとに理由と代替手段を返すようにしました。実際に返しているのはこういうメッセージです。

message=(
    "In-place sed is blocked by a static rule in settings.json. Nobody "
    "blocked this interactively. Use the Edit tool, which shows the diff "
    "and cannot silently rewrite a whole file."
),

この記事では、Bash 向けの deny をやめた理由、プレフィックス一致では見えていなかった書き方、止める範囲を引き直した基準を書きます。実装は corrupt952/dotfiles の modules/claude/claude-bash-guard.py にあります。

止めるだけだと、代替手段の探索をモデルに任せることになる

もとの設定はこうでした。

{
  "permissions": {
    "deny": [
      "Bash(rm -f *)",
      "Bash(rm -rf *)",
      "Bash(curl *)",
      "Bash(wget *)",
      "Bash(git push -f *)",
      "Bash(git push --force-with-lease *)",
      "Bash(git reset *)",
      "Bash(chmod 777 *)",
      "Bash(npx *)",
      "Bash(pnpx *)"
    ]
  }
}

Configure permissions を読む限り、deny ルールにメッセージや理由を添える手段は用意されていません。ルールが持てるのはパターンだけです。

止められた側は、何が引っかかったのか分からないまま次の手を考えることになります。sed -i を止めたときに Edit ツールへ切り替えてほしくても、そこへ行き着くまでに別の書き方を試しては、同じルールにもう一度当たる、ということが起きます。使ってほしい代替手段はこちらが最初から知っているのに、探索のぶんだけトークンを使っている状態です。

その探索が明後日の方向へ行く場合もあります。settings.json に書いた静的なルールで止まっただけなのに、ユーザーが拒否したという趣旨の報告をして作業自体をやめてしまう、という挙動を何度か見ました。その場で止めた人は誰もいません。止めたかったのはそのコマンドであって、作業ではありません。

止めた理由と代替手段を、同じメッセージで返す

Hooks reference の PreToolUse は、ツール呼び出しの前に割り込んでブロックできます。exit code 2 の扱いはこう書かれています。

Exit 2 means a blocking error. On events that can block, exit 2 blocks whether or not you print JSON: even a JSON permissionDecision of "allow" can’t override it.

The blocking message is the reason from your JSON’s blocking decision when it makes one, and your stderr text otherwise.

— Hooks reference

つまり stderr に書いた文がそのままブロック理由としてモデルに渡ります。ここに代替手段を書けます。

理由を返す手段は exit 2 だけではありません。JSON で permissionDecision に "deny" を返し、permissionDecisionReason に理由を入れる形でも同じことができます。どちらを選んでも構いません。deny ルールとの差は exit code の話ではなく、フックであれば理由を返す口があるという一点です。

各ルールのメッセージは3つの部分でできています。

message=(
    "git reset --hard is blocked by a static rule in settings.json. "
    "Nobody blocked this interactively. It throws away uncommitted work "
    "with no way back. Use git restore to discard specific files, git "
    "revert to undo a commit, or git reset --soft to move HEAD while "
    "keeping the working tree."
),

1文目は静的な設定によるものだと明示します。2文目の「Nobody blocked this interactively」は、誰も対話的に止めていないことを言い切る役です。残りは代替手段になります。前半2文は作業をやめてしまう挙動に、後半は探索そのものに効かせています。

代替手段がないものについては、そう書いてあります。

message=(
    "Force-pushing is blocked by a static rule in settings.json. Nobody "
    "blocked this interactively. There is no safe alternative: tell the "
    "user what you want to force-push and why, and let them run it."
),

プレフィックス一致では見えない書き方がある

deny を移すにあたって、もうひとつ分かったことがあります。Bash(rm -rf *) はコマンド文字列の先頭としか照合しないので、少し書き方を変えるだけで素通りします。

git status && rm -rf build
find . -name '*.tmp' | xargs rm -rf
sudo /bin/rm -rf /tmp/x

3つとも rm -rf を実行しますが、Bash(rm -rf *) はどれにもマッチしません。文字列が rm で始まっていないからです。

find . -name '*.tmp' | xargs rm -rf を2通りに読んだ結果。プレフィックス一致は先頭が find なので一致せず素通りさせるが、トークナイザーはパイプで分割して xargs を剥がし、rm -rf にたどり着き、-f が付いているのでブロックする

そこでフックは、生の文字列を照合するのをやめて shlex でトークンに分解しています。

prepared = command_line.replace("\n", " ; ").replace("\r", " ; ").replace("`", " ; ")

lexer = shlex.shlex(prepared, posix=True, punctuation_chars=True)
lexer.whitespace_split = True

改行とバッククォートを先に区切り文字へ置き換えているのは、shlex が改行をただの空白として扱い、2つのコマンドを1つに繋いでしまうためです。バッククォートによるコマンド置換に至っては概念そのものを持っていません。

分解したあとは、&& や | などの区切りでコマンド単位に割り、それぞれの先頭から本体でないものを剥がします。剥がす対象は3種類です。

  • then や do のようなキーワード
  • FOO=bar の形の代入
  • env、sudo、timeout、xargs のような、他のコマンドの前に置かれるだけのラッパー

ラッパーには自分の引数を持つものがあるので、timeout 30 や nice -n 10 の数値まで含めて落とします。最後に /bin/rm のようなパスは basename だけにして、ようやくルールに掛けます。

引用符が閉じていない壊れた入力に当たったときは、雑な分割に切り替えて照合を続けます。パースに失敗したから素通しにする、という作りにすると、そこが回避経路になるためです。

意図に合わせてルールを見直す

移す作業のついでに、元の deny が実際の意図とずれていた箇所を直しました。

  • rm は -f が付いた形だけを止めます
    • 危ないのは再帰ではなく、間違ったパスを消そうとしたときに出るはずの確認と失敗を握り潰すことのほうだからです
  • git reset は --hard だけを止めます
    • 取り返しがつかないのはこの形だけで、--soft や --mixed まで巻き込むと普通の操作ができなくなります
  • curl と wget は localhost 宛を通します
    • 自分で立てたサーバーの確認は日常的な作業なので、そこまで止める理由がありません
  • レジストリから取ってきて実行する系は逆に広げました
    • 元の設定は npx と pnpx だけでしたが、pnx、bun x、npm exec、corepack、uvx、pipx、gem exec、dnx、jbang を追加しています
    • ローカルにあるものを実行するだけの pnpm exec などは対象外のままです
  • インタープリターのワンライナーは -c と -e の両方を見ます
    • python -c に加えて node、ruby、perl の -e が対象です

封じ込めは sandbox に任せている

このフックは封じ込めではありません。ペイロードが壊れていれば exit 0 を返し、通常の権限フローへ流します。

try:
    payload = json.load(sys.stdin)
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
    return 0

フックがタイムアウトした場合もブロックしません。本当の封じ込めは sandbox の設定側にあり、こちらはあくまで「その書き方はやめて、こっちを使え」と伝えるための層です。

だから、モデルに迂回されては困るものは deny に残してあります。

{
  "permissions": {
    "deny": [
      "Read(**/.env)",
      "Read(**/.env.*)",
      "Read(**/secrets/**)",
      "Read(**/*.pem)"
    ]
  }
}

読み取りの禁止は、代替手段を案内する話ではありません。理由を返して作業を続けさせたいものと、そもそも回り道をされたくないものは別で、後者は deny に置いたままにします。

検証したことと判断できなかったもの

ユニットテストは188件で、ソースと Nix のビルド成果物の両方に対して通します。実際に走らせるとこうなります。

pass=188 fail=0

実セッションでも動かしました。sed -i を止めたら Edit ツールへ、リモートへの curl なら WebFetch へ、いずれもターンを止めずに切り替わりました。

判断できなかったものも残っています。nix run や brew install、docker pull も取得と実行を兼ねますが、止めるとこのリポジトリでの普通の作業が成り立ちません。dotslash や deno task、docker run は argv だけでは中身が判断できないので手を出していません。ヒアドキュメントの本文もコマンドとして走査するため、ブロック対象のコマンドに読める文章を書くと誤検知します。

このうち誤検知は、フックの性質上そこまで痛くありません。止められた側には理由と代替手段が返るので、探し直さずに次の手へ進めます。deny のままだったら、同じ誤検知のたびに探索の時間がかかっていました。