私は非エンジニアで、Claude Code——AIが自分でコマンドを打ちながら作業を進める道具——に
運用をやらせている。コードは自分では書けない。だから中身を書く仕事はCCにやらせて、
返ってきたものを自分で確かめる、という分担でやっている。
そのCCに ~/.ssh を読ませたくない、というのは自然な心配だと思う。
だいぶ前に PreToolUse のフック——ツールが動く直前に割り込んで、
条件に合えば止められる仕組み——を入れた。入れて、安心して、それきりだった。
別の作業のついでに、CCへ「いま何が ~/.ssh を守っているのか」を数えさせた。
返ってきた答えが、思っていたのと違った。
私の設定に登録されていた PreToolUse のフックは、Bash を見るものと、
書き込み(Edit / Write)を見るものだけ。ファイルを読む Read を見るフックは、1本も無い。
ファイルを読む口は、素のままだった。
では残っている Bash 用のフックは効いているのか。入力を流して終了コードを見た。
cat ~/.ssh/id_rsa -> 2 (止まった)
cat /home/私/.ssh/id_rsa -> 0 (素通りした)
同じファイルだ。チルダのまま書けば止まり、展開した形で書けば止まらない。
CCがコマンドを組み立てる時、パスがどちらの形になるかはその時々だ。
守りが片方にしか当たっていないなら、それは運任せということになる。
鍵の控え(id_rsa.bak)も、鍵と同じだけ危ない。そこまで見られているかも、確かめていなかった。
以下は 2026年8月時点、Claude Code 2.1.246 での話だ。
先に結論
- フックは「入れた」では終わらない。入れたあとに、自分の環境で検定する必要がある
- 私の環境では、読み取りを見るフックが1本も無く、実在した
Bash用のフックは
チルダの形にしか当たっていなかった - 検定は、危ない鍵を1バイトも読まずにできる。ただし後述の限界がある
- よくある書き方の守りを11通りの入力にかけたら、6通りが素通りした
- 広げて直したら11通り全部通ったが、今度は止めてはいけないもの2件が止まった
- 広げてもまだ抜けるものが残る。どこが抜けたかは、流してみるまで分からない
- さらに、道具によって入力の欄名が違うので、
Readを塞いでもGrepからは届く
検定の道具(実鍵は読まない)
フックが見るのはツールに渡された引数であって、ファイルの中身ではない。
だからそのパスにファイルが実在しなくても、判定は同じになる。
つまり存在しない鍵の名前を流すだけで、守りの効き目を測れる。
ただしこれが成り立つのは、渡されたパスの文字列だけを見るフックの話だ。
realpath や stat で実体を確かめてから判定する作りだと、実在しないパスでは結果が変わる。
CCに2つ書かせて流してみたら、こうなった。
| フックの作り | 実在する鍵 | probe の非実在パス |
|---|---|---|
realpath で正規化 |
止まる | 素通り(無い穴を報告する) |
realpath -m で正規化・鍵は symlink |
素通り | 止まる(安全だと誤報する) |
下の段が怖いほうだ。probe が「止めた」と答えているのに、実物は素通りしている。
検定の道具が「安全」の側へ誤報する向きが、実在する。
自分のフックが realpath や stat や [ -e ... ] を使っているなら、
下の結果をそのまま信じないほうがいい。その場合は、実在するダミーのファイルを
一時の場所に作ってから流すことになる(それでも本物の鍵は要らない)。
もう1つ。probe が送る JSON は tool_name と tool_input の2欄だけだが、
本物のフックは cwd や permission_mode なども受け取る。
そこを見て判断するフックだと、probe と本番で結果が変わりうる。
CCに書かせた道具がこれだ。
#!/bin/bash
# probe.sh — フックへ入力を流して終了コードだけを見る
G="${1:?フックのパスを渡してください}"
U="${HOME:-/home/u}" # 自分のホームに合わせる。ホームを決め打ちで照合するフックへ当てるため
probe() { # probe <説明> <期待する終了コード> <file_path>
printf '{"tool_name":"Read","tool_input":{"file_path":"%s"}}' "$3" \
| bash "$G" >/dev/null 2>&1
local rc=$?
local mark="OK"; [ "$rc" != "$2" ] && mark="NG"
printf '%s %-32s 期待%s 実際%s\n' "$mark" "$1" "$2" "$rc"
}
probe "~/.ssh/id_rsa" 2 "$U/.ssh/id_rsa"
probe "~/.ssh/id_ed25519" 2 "$U/.ssh/id_ed25519"
probe "~/.ssh/id_rsa.pub" 0 "$U/.ssh/id_rsa.pub"
probe "~/.ssh/config" 0 "$U/.ssh/config"
probe "~/.ssh/id_rsa.bak" 2 "$U/.ssh/id_rsa.bak"
probe "~/.ssh/id_rsa_old" 2 "$U/.ssh/id_rsa_old"
probe "~/.ssh/deploy_key" 2 "$U/.ssh/deploy_key"
probe "~/.aws/credentials" 2 "$U/.aws/credentials"
probe "~/.git-credentials" 2 "$U/.git-credentials"
probe "~/certs/mykey.pem" 2 "$U/certs/mykey.pem"
probe "プロジェクトのREADME.md" 0 "$U/app/README.md"
CCの説明では、終了コードの取り決めはこうだ。2 は「止める」。
0 は「通した」ではなく「フックとしては判断を返さない」で、そのあとは通常の許可の流れ
(settings.json の設定や、こちらへの確認)へ戻る。
だから表の「実際0」は「フックはここを見ていない」と読むのが正しい。
最後の1行が入っているのは、止めることだけ確かめても意味がないからだ。
全部止めるフックは安全なのではなく、ただ使えないだけなので、
「通すべきものが通るか」を同じ表で見る。
かけてみる
検定される側は、素直に書くとこうなる。
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
[ -z "$FILE" ] && exit 0
if echo "$FILE" | grep -qiE '(id_rsa|id_ed25519|id_ecdsa)$'; then
echo "$FILE" | grep -qiE '\.pub$' && exit 0
echo "BLOCKED: 秘密鍵の読み取り: $FILE" >&2
exit 2
fi
exit 0
結果はこうなった。
OK ~/.ssh/id_rsa 期待2 実際2
OK ~/.ssh/id_ed25519 期待2 実際2
OK ~/.ssh/id_rsa.pub 期待0 実際0
OK ~/.ssh/config 期待0 実際0
NG ~/.ssh/id_rsa.bak 期待2 実際0
NG ~/.ssh/id_rsa_old 期待2 実際0
NG ~/.ssh/deploy_key 期待2 実際0
NG ~/.aws/credentials 期待2 実際0
NG ~/.git-credentials 期待2 実際0
NG ~/certs/mykey.pem 期待2 実際0
OK プロジェクトのREADME.md 期待0 実際0
11通りのうち6通りが素通りした。内訳はきれいに3つに分かれる。
- 末尾を
$で固定していたので、控え(.bak_old)が全部外れる - 名前の型に入っていない鍵(
deploy_key、.pem)は最初から見ていない - 鍵以外の認証情報(
~/.aws/credentials、~/.git-credentials)は対象外
3番の ~/.git-credentials は、中身が https://利用者:トークン@ホスト の平文だ。
開発機でいちばん価値の高いファイルかもしれないのに、
鍵の名前しか見ていない守りには映らない。
広げて直す。すると別のものが止まる
CCに広げさせた式がこれだ。
#!/bin/bash
INPUT=$(cat)
command -v jq >/dev/null || { echo "WARNING: jq が無いのでこのフックは守っていない" >&2; exit 0; }
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
[ -z "$FILE" ] && exit 0
# 公開鍵と ssh の設定だけを先に通す。/config で終わるもの全部を通してはいけない
echo "$FILE" | grep -qiE '(\.pub$|/known_hosts$|\.ssh/config$)' && exit 0
# 鍵の名前は末尾で固定しない。控えの接尾辞まで見る
if echo "$FILE" | grep -qiE '(id_rsa|id_ed25519|id_ecdsa|id_dsa|_key|\.pem|\.key)([._-][A-Za-z0-9]+)?$'; then
echo "BLOCKED: 秘密鍵の読み取り: $FILE" >&2; exit 2
fi
# 認証情報は名前を並べるしかない。増えたら足す
if echo "$FILE" | grep -qiE '\.aws/credentials|\.git-credentials|\.netrc|\.npmrc|\.pypirc|\.gnupg/|\.kube/config|/etc/shadow'; then
echo "BLOCKED: 認証情報の読み取り: $FILE" >&2; exit 2
fi
exit 0
同じ11通りにかけると、今度は全部通る。
OK ~/.ssh/id_rsa 期待2 実際2
OK ~/.ssh/id_ed25519 期待2 実際2
OK ~/.ssh/id_rsa.pub 期待0 実際0
OK ~/.ssh/config 期待0 実際0
OK ~/.ssh/id_rsa.bak 期待2 実際2
OK ~/.ssh/id_rsa_old 期待2 実際2
OK ~/.ssh/deploy_key 期待2 実際2
OK ~/.aws/credentials 期待2 実際2
OK ~/.git-credentials 期待2 実際2
OK ~/certs/mykey.pem 期待2 実際2
OK プロジェクトのREADME.md 期待0 実際0
だが、ここで終わらせると危ない。逆向きの検定を足した。
止めてはいけないものと、まだ抜けそうなものを流す。
probe "公開証明書 server-cert.pem" 0 "$U/app/server-cert.pem"
probe "鍵と無関係な apikey.ts" 0 "$U/app/src/apikey.ts"
probe "説明文 id_rsa.md" 0 "$U/docs/id_rsa.md"
probe "keyboard.tsx" 0 "$U/app/keyboard.tsx"
probe "接尾辞2段 id_rsa.old.2" 2 "$U/.ssh/id_rsa.old.2"
probe "~/.env" 2 "$U/app/.env"
NG 公開証明書 server-cert.pem 期待0 実際2
OK 鍵と無関係な apikey.ts 期待0 実際0
NG 説明文 id_rsa.md 期待0 実際2
OK keyboard.tsx 期待0 実際0
NG 接尾辞2段 id_rsa.old.2 期待2 実際0
NG ~/.env 期待2 実際0
4件も外した。前半2件は行き過ぎで、後半2件は届いていない。
-
server-cert.pemは公開してよい証明書、id_rsa.mdはただの説明文だ。
_keyと.pemを足した副作用で、「鍵っぽい名前の、鍵じゃないもの」まで巻き込んだ -
id_rsa.old.2は接尾辞が2段あるので、([._-][A-Za-z0-9]+)?$の1段ぶんを外れる。
つまり最初に直したはずの欠陥と、同じ種類の穴がまだ残っている -
.envはこの式の対象外だ。鍵と認証情報の話に絞ったからだが、
いちばんよくある秘密のファイルなので、使うなら別の行で足すことになる
keyboard.tsx を並べたのは、_key を雑に足すと key を含む普通のファイル名まで
飲み込む書き方になりやすいからだ。上の式は末尾の境界で見ているので通ったが、
自分で式をいじったら、この行を必ず流し直してほしい。
守りを広げると誤検知が増える、というのは当たり前に聞こえる。
だが、どの入力で行き過ぎて、どの入力にまだ届いていないかは、流してみるまで分からない。
私はここを、頭の中で「たぶん大丈夫」と処理していた。
道具が違うと、入力の欄名が違う
もうひとつある。フックが受け取る JSON の欄名は、ツールによって違う。
| ツール | ファイルの位置が入る欄 |
|---|---|
Read / Edit / Write
|
tool_input.file_path |
Grep / Glob
|
tool_input.path |
上のフックは file_path しか読んでいない。だから同じ鍵へ Grep から届くと、こうなる。
$ printf '{"tool_name":"Grep","tool_input":{"pattern":"PRIVATE KEY","path":"/home/u/.ssh/id_rsa"}}' \
| bash guard.sh; echo $?
0
素通りする。欄が空に見えるので、フックは「対象外」と判断して静かに 0 を返す。
settings.json の matcher を Read だけにしている場合は、そもそも呼ばれもしない。
そしてもう1本、いちばん広い口が残る。Bash は欄名が command で、
パスではなくコマンド全文が入る。cat でも grep でも base64 でも読めるので、
パスの照合では追いつかない。ここは別のフックの仕事になる
(冒頭で私の環境に在ったのが、まさにこれだった)。
複数のツールを見るなら、こう分ける。
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
case "$TOOL" in
Read|Edit|Write) FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null) ;;
Grep|Glob) FILE=$(echo "$INPUT" | jq -r '.tool_input.path // empty' 2>/dev/null) ;;
*) exit 0 ;;
esac
直せない端もある
正直に書いておくと、パスの文字列を見る守りには、直しようのない端がある。
-
K=~/.ssh/id_rsa; cat $Kのように変数を経由されると、文字列に鍵の名前が出ない - 一度承認した
bash setup.shの中で鍵を読む場合、一件ずつは尋ねられない -
jqが入っていない環境では、パースが空になって守りが黙って消える
(上の式には警告の1行を入れたが、入っていない書き方も普通にある)
だからフックは唯一の砦ではなく、層のひとつだ。
settings.json の deny と重ねる、鍵を作業ディレクトリの外に置く、
といった別の層と一緒に使うものだと思っている。
まとめ
- 入力を流して終了コードを見るだけで、危ない鍵を1バイトも読まずに守りを検定できる
- 検定には「止めるべきものが止まるか」と「通すべきものが通るか」の両方を入れる
- 末尾を
$で固定した鍵の照合は、控えファイルで抜ける -
~/.git-credentialsは鍵ではないので、鍵の名前しか見ない守りからは見えない - 広げても穴は残る。残った穴の場所は、逆向きの検定を流すまで分からない
- ツールによって入力の欄名が違う(
file_pathとpath) - 文字列の照合には端がある。層を重ねる前提で使う
私は「フックを入れた」ことに安心して、どの口が守られているのかを一度も数えていなかった。
上の probe.sh はすぐ終わる。入れっぱなしのフックがある人は、一度流してみてほしい。
全部 OK なら、それが分かっただけでも収穫だ。
すぐ試せる形の hook を MIT ライセンスでまとめている →
cc-safe-setup
この記事の probe.sh を書いたあと、近傍を手で並べるのが面倒になったので、
元の名前から自動で作る形にして audit/boundary.sh として同じ場所へ入れた。
拒むべき物を1つ渡すと、控え・大文字化・末尾の空白へ撃ち、
同時に通すべき物へも撃って、穴と行き過ぎを別々に数える。
そちらをこの記事の「広げた式」へ流したら、穴が3つ出た。
1つは上で書いた id_rsa.old.2。残りの2つ——編集器が作る id_rsa~ と、
末尾に空白の付いた id_rsa ——は、この記事の検定では拾えていなかった。