Claude Code の PostToolUse hook で、Claude in Chrome の list_connected_browsers(接続中のブラウザ一覧)が返るたびに、「どのブラウザがどの Google アカウントのプロファイルか」の対応表を、Chrome のファイルを読んで作り、会話に差し込んでいました。
2026年9月28日に macOS を 27.0 に上げた日の午後から10月5日の朝まで、この表は一度も出ませんでした。セッションの記録で数えると、16のセッションで一覧を20回呼び、表が付いたのは0回です。10月5日の診断では、~/Library/Application Support/Google/Chrome の中身の読み取りが Operation not permitted(EPERM)で失敗していました。hook は「確かめられないときは表を出さない」と決めて作ってあり、そのとおりに黙っていました。それ以前の20回も同じ原因だったと見ていますが、その回の hook の実行は記録に残っておらず、確かめられません。
表が出ていないことには毎日気づいていて、作業記録の7回すべてに「対応表が付かない」と書き、うち4回には「理由は未調査」と書いています。表が無ければ人に聞く決まりで作業は回っていたので、調べる理由が見えなかったのです。
以前の記事では、相手のサーバーが HTTP 200 を返しながら古いデータを返し続けた件を書きました。今回は相手ではなく、自分で「黙れ」と書いた hook が、書いたとおりに黙った話です。
前提と限定
- ノートPC(Apple silicon)1台の記録です。仕事用と個人用で Google アカウントと Chrome のプロファイルを分け、それぞれに Claude in Chrome の拡張を入れています
- OS はインストール履歴で、macOS 26.6.2(8月19日)→ 27.0(9月28日 11:24)→ 27.0.1(10月1日)。10月25日の時点で 27.0.1(ビルド 26A434)です
- Claude Code は黙っていた間に 2.1.283 から 2.1.289 へ。10月5日の切り分けは 2.1.289、10月25日は 2.1.310 です
- EPERM は、10月5日と25日に Claude Code の Bash から確かめました(10月5日はサンドボックスを外しても同じ。10月25日は端末アプリ Orca の中)。10月25日には、Claude Code を挟まず、フルディスクアクセスを付けていない端末アプリのシェルから同じ
lsを打ってもOperation not permittedでした - macOS のどの保護で止められているのかも、フルディスクアクセスを付ければ読めるのかも(付けない判断をしたので)確かめていません
- 私はエンジニアではなく、コードは AI に書かせ、別の AI(Codex)にレビューさせています
hook がしていたこと(v1〜v5)
10月5日に見た list_connected_browsers の応答には、ブラウザごとの deviceId・表示名(「Browser 1」など)・inUse などがあり、アカウントを示す項目はありませんでした。表示名は同じセッションの中でも入れ替わります(8月に実測)。そこで9月10日に hook を書きました。拡張は自分の deviceId をプロファイル内の拡張用ストア(LevelDB)に持っているので、接続中の ID をそこから探し、Chrome の Local State の profile.info_cache でプロファイルとアカウントを結びます。「ID・プロファイル・アカウント」が1対1で結べた行だけを、自動で選んでよい行として出します。
アカウントを取り違えると、個人のブラウザで仕事のアカウントを操作するような事故になります。だから照合の条件を満たさなければ、表を出さないと決めました。表が無ければ、Claude は従来どおり人にどのブラウザを使うかを聞く決まりにしていました。
実装の最後はこうでした(v5・9月11日)。
if __name__ == "__main__":
try:
main()
except BaseException: # never disturb the tool result; silence = "ask as before"
pass
sys.exit(0)
登録する設定(settings.json)でも、2>/dev/null || true で標準エラーと終了コードを捨てていました。
v5 は別系統のレビューを5巡通し、回帰テスト45件で固めたものです。45件のうち35件は名前が「〇〇なら沈黙」のテストで、その中には「ファイルが1つ読めない → 沈黙」もありました。読めないときに黙ることは、狙いどおりの振る舞いとして確かめてありました。 確かめていなかったのは、黙ったときに、誰がどうやってそれを知るかです。
9月28日に起きたこと
Claude Code のセッション記録(~/.claude/projects/ 配下の jsonl)から拾いました。
| 日時(JST) | Claude Code | 表 |
|---|---|---|
| 9/28 11:07 | 2.1.283 | 付いた(これが最後) |
| 9/28 11:24 | — | macOS 27.0 のインストール(履歴の時刻) |
| 9/28 14:22 | 2.1.283 | 付かない(これが最初) |
| 9/28 14:22〜10/5 09:31 | 2.1.283〜2.1.289 | 16セッション・20回、すべて付かない |
| 10/5 09:49 | 2.1.289 | 付いた(直したあと) |
付いた最後の回と付かなかった最初の回は、どちらも Claude Code 2.1.283 です。その約3時間に、インストール履歴と版から分かる変化は OS の更新だけなので、引き金はそれだと見ています。同じ日に Chrome や拡張が更新されたかは確かめていません。
もう1つ。表が出た回には、この hook の実行記録(標準出力つき)が jsonl に残り、黙った回には1行もありません(同じ呼び出しの別の hook の記録は残っています)。会話の記録だけでは、「走って失敗した」と「走らなかった」を区別できませんでした。(出力の無い hook を記録しない作りなのかは、確かめていません)
気づいていたのに、調べなかった
冒頭の記録は、毎日ブラウザで行う定型作業のものです。調べなかったのは、黙ったときの決まり(人に聞く)が働いて、作業は毎日できていたからです。ただ、そのあいだ取り違えを防いでいたのは、Claude Code に読ませる指示ファイル(CLAUDE.md)の2つの決まりだけでした。
- 表が付いていなければ、人にどのブラウザを使うか聞く
- ブラウザを選んだら、操作の前に
myaccount.google.comに表示された Google アカウントを目的のものと照らし、違えば操作を止めて人に知らせる
危うかった日が2日あります。
- 10月1日: 人に聞いて返ってきたアカウントのブラウザに切り替え、2の確認も通ったが、そのプロファイルでは作業で使うサービスに1つもログインしていなかった。操作の前に気づいて聞き直した
- 10月2日: Claude が1を飛ばし、ツールの既定のブラウザ(前日に確かめたのと同じ ID)を2の確認だけで使った。結果は正しかったが、記録には「規則から外れた」とある
ツールは、前に選んだブラウザを聞かずに使う作りでした(10月5日の応答は N browsers are connected; "Browser 2" (deviceId …) is the one picked last and will be used.。台数・ID は伏せた)。
正確に書くと、hook がしていたのは、会話に対応表を足すこと(additionalContext)までで、表に従うことも、表が無いときに聞くことも強制していません。PostToolUse は実行後に走るので、すでに実行された当該の操作を事前に止めるものでもありません。**黙ったことで、対応表による補助が消え、人への確認と、AI がその決まりを守ることに頼る状態になっていました。**10月2日の逸脱は、その限界が出た例です。
10月5日の切り分け
10月5日の朝、初めて理由を調べました。仮説は2つありました。
- (a) ツールの応答の形が変わった。画面では、JSON の配列のすぐ後ろに上の英文が続いて見えた。配列の後ろに文字が付けば、hook の
json.loadsは失敗する - (b) Chrome のフォルダが読めなくなった
画面で見えたとおりに「配列+英文」をつないだ文字列を hook の関数に渡すと JSONDecodeError: Extra data、Chrome のフォルダを開く関数は PermissionError: [Errno 1] Operation not permitted で落ちました。Claude Code のサンドボックスを外しても同じです。
両方とも再現したので、先に (a) を直しにかかりました(配列の後ろの文字を読み飛ばす修正)。あわせて、黙ったときに理由を書き残す仕組み(後述の沈黙ログ)を足し、本物のツールを1回呼びました。残った記録がこれです。
{"at": "2026-10-05T09:38:58+09:00", "result": "silent", "stage": "chrome_dir",
"error": "PermissionError", "errno": "EPERM",
"shape": {"tool_response": "list", "n_blocks": 2,
"blocks": [{"type": "text", "len": 554, "array_parses": true, "trailing_chars": 0},
{"type": "text", "len": 250}]}}
shape を見ると、hook が受け取った応答は2つのブロックに分かれ、配列のブロックは単独で JSON として読めて、後ろの余計な文字は0でした。画面でつながって見えただけで、(a) は誤りです。30秒ほど後に読み飛ばしの修正は戻しました。止まった段は chrome_dir・EPERM で、この呼び出しで黙った原因は (b) でした。
落とし穴は、再現の材料が画面での見え方で、hook が受け取るものではなかったことです。沈黙ログが最初にした仕事は、私の仮説を1つ潰すことでした。
10月25日に測り直した範囲
macOS 27.0.1(26A434)で、ls ~/Library/"Application Support"/Google/Chrome/ は Operation not permitted、ls -ld は通りました。Python で操作ごとに分けると、こうなりました。
python3 - <<'EOF'
import errno, os
d = os.path.expanduser("~/Library/Application Support/Google/Chrome")
for label, f in [("stat", os.stat), ("listdir", os.listdir),
("open(Local State)", lambda p: os.close(os.open(os.path.join(p, "Local State"), os.O_RDONLY)))]:
try:
f(d)
print(label, "通る")
except OSError as e:
print(label, errno.errorcode.get(e.errno))
EOF
stat 通る
listdir EPERM
open(Local State) EPERM
ls -ld や os.path.isdir が通ることは、読める証拠になりません。
~/Library/Application Support の直下と Google の下のフォルダを1つずつ os.listdir すると、EPERM は直下で10個(Apple のアプリのものに混じって Microsoft Edge)、Google の下では Chrome だけで、Chrome for Testing などは読めました。macOS 26 のころとは比べていません。
macOS のサンドボックスは、非公開の関数 sandbox_check(pid, NULL, 0) を ctypes で呼ぶ補助的な観測で見ました。Bash ツールのシェル(zsh)から起動した Python と、親の zsh のどちらも 0(掛かっていない)でした。TCC の許可の状態は、これでは分かりません。
直し方の選択肢
| 10月5日に並べた案 | 得るもの | 失うもの |
|---|---|---|
| 端末アプリにフルディスクアクセスを付ける | hook のコードを変えなくてよい(読めるようになる見込みだが、試していない) | 下の見立てどおりなら、端末アプリに帰属すると判定される処理(Claude Code・Codex・hook など)に、フルディスクアクセスの対象のデータを読む許可が及ぶおそれがある |
| 手で確かめた組だけを表にする | OS の権限を変えない。Chrome の内部ファイルの形にも依らなくなる | 表は確かめた時点の記録でしかない。拡張を入れ直すと登録し直し |
| 直さない(沈黙ログだけ足す) | 何も変えない | 毎回人に聞く |
フルディスクアクセスを付けなかったのは、1つのフォルダのために、多くのセッションが読める範囲を広げるおそれがあるからです。別の常駐ジョブでは、許可を付けたプログラムを自分のシェルから起動すると EPERM のままで、launchd から起動すると読めました(2026年8月)。この差から TCC での帰属(責任プロセス)の違いを疑っていますが、この hook の拒否の要因・帰属先・フルディスクアクセスの効果は、どれも確かめていません。許可を付けた専用のプログラムを launchd から起動して読ませる案も、別件から考えただけで検証していません。
v6: 確かめた組だけを表に残す
10月5日に、Chrome のファイルを読むのをやめました。
- 表はホストごとに git の外に置く(
~/.local/state/chrome-browser-map/verified.json・0600) - 記録するのは、そのブラウザで
myaccount.google.comを authuser 指定なしで開いたときに表示された Google アカウント。選んだあとに必ずやる確認(上の2)と同じ作業なので、手間は増えない - 登録・削除・一覧は hook と同じファイルのサブコマンド(
record <deviceId> <email>/forget <deviceId>/show)。排他ロックを取り、一時ファイルに書いてから置き換える - hook は、接続中の ID が表にあり、自動で選んでよいアカウントに完全一致し、接続中の中で1対1のときだけ「選んでよい」行を出す。表に無い ID は「未検証・自動選択しない」の行にする。表が壊れている・接続中の2つの ID に同じアカウントがある・選べる行が0、なら今までどおり黙る
- ツールが既定で使おうとしているブラウザ(応答の
inUse)に印を付け、目的のアカウントと違えば切り替えるよう書き添える
表は所有の証明ではなく、確かめた日にそのページに表示されたアカウントの記録です。Chrome の同期アカウントや、作業で使うサービスのログイン先までは保証しないので、それは作業のときに別に確かめます(10月1日に止まったのはそこでした)。拡張を入れ直して ID が変われば「未検証」に落ちます。
回帰テストは50件、別系統のレビューは2巡です(commit の記録)。
沈黙ログ
v6 では、hook が走るたびに ~/.local/state/chrome-browser-map/last.json を上書きします。
-
at(時刻)・result(ok/silent)・stage(最後に到達した段) - 黙ったときは
error(例外のクラス名)と、OSError ならerrno -
shape: 受け取ったツール応答の形だけ(ブロックの種類と長さ、配列が JSON として読めるか、後ろに何文字あるか)
deviceId・メールアドレス・表示名・パスは書きません(ログ自体が漏れ口にならないように)。10月25日 09:23 の記録は result: ok・stage: rows でした。
読み方です(段の名前は私の hook のもの)。
| last.json の中身 | 意味 |
|---|---|
at が、一覧を呼んだ時刻より古い |
その呼び出しの記録が無い。未起動・途中終了・書込失敗・別の呼び出しによる上書きは、このファイルだけでは区別できない |
silent・stage: rows・error: Abort
|
表は読めたが、出す条件を満たさず止めた。実装では「選べる行が0」と「接続中の2つの ID に同じアカウント」の両方がこれになり、どちらかはこの記録だけでは分からない |
silent・stage: table・FileNotFoundError
|
表がまだ無い |
silent・PermissionError(errno は EPERM か EACCES) |
読み取りを拒まれた。どの仕組みが拒んだかは、errno だけでは決まらない |
silent・stage: connected_ids
|
ツール応答の形が想定と違う。shape を見る |
EPERM と EACCES は、Python ではどちらも PermissionError です。この記事の実測では、Chrome の読み取りで EPERM、chmod 000 の試験(後述)で EACCES でした。errno は切り分けの手掛かりになりますが、拒んだ仕組みを決めるには別の確認が要ります。
この沈黙ログにも限界があります。
- 上書きなので最後の1回しか分からない。呼び出しごとの識別子や、開始と終了の記録も持っていない
- 自分からは何も知らせない。変わったのは、気づいたときに理由が1ファイルで分かるようになったことだけ
- ログ自体が書けないときは、それも黙る
最小の再現
作法だけを取り出した最小例です。対象フォルダの中身の数を返すだけの hook で、例外が起きたら何も出さず、沈黙ログに段と例外を残します。対象は環境変数で差し替えられ、既定は Chrome のプロファイル置き場です。
#!/usr/bin/env python3
"""確かめられなければ何も出さない PostToolUse hook の最小例。
なぜ黙ったかは沈黙ログに残す: ok=出力処理が例外なく戻った/nothing=出すものが無かった/
silent=例外で黙った(stage=どの段か・error=例外のクラス名・errno)"""
import contextlib, datetime, errno, json, os, sys, tempfile
TARGET = os.path.expanduser(os.environ.get("DEMO_TARGET", "~/Library/Application Support/Google/Chrome"))
STATE = os.path.expanduser(os.environ.get("DEMO_STATE", "~/.local/state/demo-hook/last.json"))
stage = "start"
def log(result, exc=None):
try: # 記録に失敗しても hook は止めない(そのときは記録も残らない=この方式の限界)
rec = {"at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
"result": result, "stage": stage}
if exc is not None:
rec["error"] = type(exc).__name__
if isinstance(exc, OSError) and exc.errno:
rec["errno"] = errno.errorcode.get(exc.errno, str(exc.errno))
d = os.path.dirname(STATE)
os.makedirs(d, mode=0o700, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d) # 既存のファイルを上書きしない一時ファイル
try:
with os.fdopen(fd, "w") as f:
json.dump(rec, f)
os.replace(tmp, STATE) # 書き終えてから置き換える
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
except BaseException:
pass
def main():
global stage
stage = "stdin"
json.load(sys.stdin) # PostToolUse の入力(ここでは中身を使わない)
stage = "read_target"
n = len(os.listdir(TARGET)) # macOS 27 の Chrome の置き場では、ここで EPERM になった
if n == 0:
return "nothing"
stage = "output"
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PostToolUse",
"additionalContext": f"{n} entries"}}))
return "ok"
if __name__ == "__main__":
try:
log(main())
except BaseException as e: # Ctrl-C や sys.exit も捕まえる。強制終了(SIGKILL など)は捕まえられない
log("silent", e)
sys.exit(0)
この作りがするのは、捕まえた例外について記録を試み、sys.exit(0) を呼ぶところまでです。掲載の4状態では終了コード0でしたが、強制終了や終了処理中の失敗(標準出力の書き出しなど)まで含めた保証ではありません。BaseException にしたのは、本番の hook と同じく中断や sys.exit も捕まえるためです。
4つの状態で走らせるスクリプトです。沈黙ログの置き場は一時ディレクトリにしてあり、終わったら消します。
#!/bin/bash
# demo_hook.py を4つの状態で走らせ、標準出力・終了コード・沈黙ログを並べる
set -u
HERE=$(CDPATH= cd -- "$(dirname "$0")" && pwd) || exit 1
W=$(mktemp -d) || exit 1
trap 'chmod -R u+rwx "$W" 2>/dev/null; rm -rf "$W"' EXIT
export DEMO_STATE="$W/state/last.json"
try() { # try <見出し> <対象ディレクトリ>
echo "## $1"
out=$(echo '{}' | DEMO_TARGET="$2" python3 "$HERE/demo_hook.py"); rc=$?
echo "stdout: ${out:-(なし)}"
echo "exit: ${rc}"
echo "log: $(cat "$DEMO_STATE")"
echo
}
mkdir -p "$W/full" "$W/empty" "$W/locked" && touch "$W/full/a" "$W/full/b" && chmod 000 "$W/locked" || exit 1
try "1. 読める・中身あり" "$W/full"
try "2. 読める・中身なし" "$W/empty"
try "3. 権限で読めない(chmod 000)" "$W/locked"
try "4. Chrome のプロファイル置き場" "$HOME/Library/Application Support/Google/Chrome"
結果を表にまとめます(2026年10月25日、macOS 27.0.1・Python 3.14.6・/bin/bash 3.2.57。Claude Code 2.1.310 の Bash から実行)。
| 状態 | 標準出力 | 終了コード | 沈黙ログ |
|---|---|---|---|
| 1. 読める・中身あり | {"hookSpecificOutput": … "2 entries"} |
0 |
result: ok・stage: output
|
| 2. 読める・中身なし | なし | 0 |
result: nothing・stage: read_target
|
| 3. 権限で読めない | なし | 0 |
result: silent・stage: read_target・PermissionError・EACCES
|
| 4. Chrome のプロファイル置き場 | なし | 0 |
result: silent・stage: read_target・PermissionError・EPERM
|
2〜4は、標準出力も終了コードも同じで、違うのは沈黙ログだけです(2と3・4は result、3と4は errno で分かれる)。root では3は再現しません。これはダミーの入力({})でのスクリプト単体の確認で、Claude Code に登録して会話に届くかは、このデモでは試していません(本番の hook で届いていることは次の節のとおり)。
その後(10月6日〜10月25日)
- 毎日の作業記録では、10月6日から25日までの16回すべてで表が付いた。表のファイルは10月5日から書き換えていない
- 16回のうち11回は、ツールが既定で使おうとしたブラウザが作業と別のプロファイルで、表に従って切り替えた
自分の環境で確かめるなら
- macOS 27 で
ls ~/Library/"Application Support"/Google/Chromeを、ふだんの端末と Claude Code の Bash の両方から実行して比べる(私は後者しか確かめていません) - 「失敗したら何も出さない」hook は、「出すものが無かった」と「失敗した」を分け、段と例外と errno を残す。中身は残さない
- その hook が黙ったとき、代わりに何に頼ることになるかを書き出す。それが AI へのお願いだけなら、黙ったことに気づける場所を別に用意する
まとめ
10月5日の診断では、hook は想定した例外処理のとおりに黙っていました。macOS 27 への更新のあと、Claude Code から Chrome のフォルダが EPERM で読めなくなっていたからです(それ以前も同じ原因と見ています)。黙ることはテストで確かめてあり、人への確認を含む決まりで作業は続いていた(確認を飛ばした日もあった)ので、7日間、誰も理由を調べませんでした。
変えたのは2つです。毎回 Chrome のファイルを読むのをやめ、手で確かめた組だけを表に残すようにしたこと。黙った回も、どの段で何の例外だったかを1ファイルに残すようにしたこと。黙ったことを知らせる仕組みは、まだありません。
※ この記事は Zenn に投稿した同名記事(https://zenn.dev/masamitsu_sera/articles/58916187eada6a )の転載です。