AI

Claude Codeの使用率ガードをTeamシートで止める

  • Claude Code
  • hooks
  • Bash
  • レート制限
  • statusLine

Claude Codeを任意の使用率で止めるガードについて、前回の記事は試作した記録まででした。残していた2つの宿題のうち「Teamプランで rate_limits が届くか」を済ませ、あわせて2つのhookをしきい値を下げて実際に発火させています。その過程でセンサー側のバグが1つ見つかりました。もう1つの宿題だった、枠を本当に使い切った状態での挙動には手が届いていません。

ブログClaude Codeを任意の使用率で止めるガードを作っているClaude Codeのrate_limitsはstatusLineにしか渡らず、実行を止められるhookには渡ってきません。この非対称をstate fileで橋渡しし、5時間枠・週次枠を任意のパーセンテージで止めるガードを試作した記録です。

Teamプレミアムシートにも同じものが届いた

statusLineのドキュメントrate_limits の提供を約束しているのはPro/Maxのサブスクリプションに対してで、ガードを作る動機になったTeamプランについては何も書かれていません。Maxアカウント(Claude Code 2.1.221)では届くことを確認済みでしたが、Teamのプレミアムシート(同2.1.228)でも同じでした。

中身も同じです。five_hourseven_day の2つの枠に used_percentageresets_at が入り、Team固有の枠が増えることもありません。ドキュメントに書かれていない以上いつ変わってもおかしくない前提は変わりませんが、少なくとも現時点では、Maxで作ったセンサーがそのままTeamで動きます。

しきい値を下げて実際に止めた

しきい値を現在の使用率より下に置いて、2つのhookをそれぞれ発火させました。

UserPromptSubmit は、週次枠を2%消費した状態に対してしきい値1%を設定し、ターンの開始を拒否しました。このとき最初のプロンプトは通っています。state fileを空にした直後だったので、状態が無ければ通すというフェイルオープンの設計どおりです。

PreToolUse はターンの内側で止まりました。セッション冒頭のプロンプトは通り、そのターンの最初の Bash 呼び出しが拒否されています。

拒否されたあとのモデルの振る舞いも見えました。1回リトライして同じ拒否に当たり、そこで回避を試みずに報告して止まっています。[guard-bypass] を自分から使うことはありませんでした。脱出口の存在は拒否メッセージの中に書いてあるので、モデルがそれを読んで自分で通り抜ける可能性は想定していましたが、今回はそうなりませんでした。1回の観測なので、そうならないと言い切れるものではありません。

センサーが前のセッションの観測値を消していた

実際に動かしたことで、センサー側に穴が1つ見つかりました。

セッションの最初のstatusLineレンダーは、そのセッションの最初のAPIレスポンスより前に起きます。この時点のペイロードに rate_limits は入っていません。ところがセンサーはそれを気にせず書き出していたので、five_hourseven_day の両方に null が入ったstate fileが出来上がっていました。前のセッションが残した正しい値は、そこで消えます。

jq は存在しないフィールドを参照しても成功するので、センサー自身が持っていた壊れた出力のチェックはこれを捕まえられません。そのチェックが見ているのは jq の失敗だけです。

直し方は、数値を含むレンダーのときだけ書き出すことでした。

jq -c '
  select(.rate_limits.five_hour != null or .rate_limits.seven_day != null)
  | { five_hour: .rate_limits.five_hour
    , seven_day: .rate_limits.seven_day
    , observed_at: (now | floor)
    }
'

select が落とせば出力は空になるので、[ -s "$STATE.tmp" ] で弾いて既存のstateをそのまま残します。これで、この状況はゲートが既に扱っている「古い観測値」に戻ります。前のセッションの数値が残り、max_age を超えた時点で古すぎると判定されて通過するだけです。

実害は思ったより小さいものでした。センサーは最初のAPIレスポンスのタイミングで走り、それはそのターンの最初のツール呼び出しより前に来ます。つまり PreToolUse の判定時には実際の数値が入っています。前節の観測(セッション冒頭のプロンプトは通り、最初の Bash が拒否された)が、まさにそのとおりになっています。

[guard-bypass]はターンを始めさせるだけ

脱出口の効き方も、実際に使ってみて理解が変わりました。

[guard-bypass] をプロンプトに含めると、そのターンは開始できます。それだけです。プロンプトを載せてくるhookイベントは UserPromptSubmit だけなので、PreToolUse はこのマーカーを見ることがなく、同じターンの中のツール呼び出しを拒否し続けます。

つまり、ツールを使う作業ではこの脱出口は役に立ちません。返事をもらうだけなら通りますが、ファイルを1つ読ませようとした時点で止まります。実際に解除するなら、残りの2つを使うことになります。設定ディレクトリに無効化用のファイルを置くと消すまで止まったままになり、環境変数を渡して起動するとそのプロセスの間だけ無効になります。

3つとも、セッションの外側から効くようにしてあります。ガードが効いている状態では、Claudeに頼んで外してもらうことができないためです。

同時に複数セッションを開くと壊れうる

まだ塞げていない穴も書いておきます。state fileはマシン内で単一のパスを共有していて、複数のセッションのあいだで排他をしていません。

数値そのものは衝突しません。rate_limits はアカウントの枠を表しているので、どのセッションから見ても同じ値であり、どれが書いたかは問題になりません。危ないのは書き込みの手順のほうです。statusline.sh$STATE.tmp という固定のパスを経由して mv するので、2つのセンサーが同じ瞬間にレンダーすると同じ一時ファイルを共有し、先に mv したほうが両者の混ざった中途半端な内容を公開しかねません。

そうなった場合、ゲートはパースに失敗して黙って通ります。フェイルオープンなので安全側ではありますが、意図して設計した挙動ではありません。一時ファイル名にプロセス固有のサフィックスを付ければ塞がる話で、まだ入れていません。

読み出し側にも似た話があります。used_percentageresets_at を別々の jq 呼び出しで取っているので、その間にファイルが差し替わると2つのフィールドが別のレンダー由来になります。mv はアトミックなので各呼び出しは丸ごとのファイルを見ており、ずれるのは組み合わせだけです。この2つのフィールドでは、どちらの組み合わせでも判定は変わりません。

claude -pは今も素通りする

ヘッドレス実行に対して無力な点は変わっていません。ここは推測ではなく測って確認しました。claude -p で走らせると、プローブはダンプを1バイトも書きません。statusLineが描画されないのではなく、statusLineコマンド自体が呼ばれていないということです。

state fileが更新されないので、max_age を超えた時点でゲートは通す側に倒れます。ヘッドレス実行を止めたいなら、state fileを経由する方式とは別の仕組みが要ります。

フェイルオープンの一覧

現在の挙動をまとめるとこうなります。

状況結果
state fileがまだ無い通す
stateが max_age より古い通す(statusLineが動いていない。claude -p など)
ペイロードに rate_limits が無い通す
resets_at を過ぎている通す(枠が切り替わっており、数値は無効)
しきい値以上止める

resets_at を過ぎているケースは、記録された使用率が既に別の枠のものになっているという意味です。古い数値でブロックし続けるのは、枠が切り替わったあとでは単なる誤作動になります。

ゲートにはテストがあり、センサーには無い

ゲートのテストは、上の表の各行、しきい値の境界、設定ファイルと環境変数の優先順位、3つの脱出口、2つのhookイベントを合わせて十数件を通しています。通過経路で標準出力が0バイトであることも、ここで固定しています。

ただし固定できているのは標準出力だけでした。テストはゲートの出力をコマンド置換で受けており、標準エラーはリダイレクトしていません。コンテキストへ注入されるのは標準出力なので実害の順序としては後ろですが、「両方0バイト」と説明していたのは言い過ぎです。

一方、センサー側にはテストがありません。今回のバグが出たのもセンサー側です。分かりにくい挙動は「数値を持たないレンダーは既存のstateを一切変えない(observed_at を含めて)」の1点なので、state無し・数値あり・数値なしの3つの状態を手作業で確認しました。テストとして固定できていないので、次に触ったときに壊す余地は残っています。

残っているのは1つ

前回から持ち越していた2つのうち、Teamプランでの確認と2つのhookの発火は済みました。残っているのは、本当に枠を使い切った状態でガードがどう振る舞うかです。しきい値を下げれば拒否の経路そのものは今回のとおり確認できますが、100%の壁とガードの拒否がぶつかったときの挙動は、実際に詰まった枠を持っていないと確かめられません。