0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

id_rsa.bak は止まるか——鍵を1バイトも読まずにフックを検定する

0
Last updated at Posted at 2026-08-30

私は非エンジニアで、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 からは届く

検定の道具(実鍵は読まない)

フックが見るのはツールに渡された引数であって、ファイルの中身ではない。
だからそのパスにファイルが実在しなくても、判定は同じになる。
つまり存在しない鍵の名前を流すだけで、守りの効き目を測れる。

ただしこれが成り立つのは、渡されたパスの文字列だけを見るフックの話だ。
realpathstat で実体を確かめてから判定する作りだと、実在しないパスでは結果が変わる。
CCに2つ書かせて流してみたら、こうなった。

フックの作り 実在する鍵 probe の非実在パス
realpath で正規化 止まる 素通り(無い穴を報告する)
realpath -m で正規化・鍵は symlink 素通り 止まる(安全だと誤報する)

下の段が怖いほうだ。probe が「止めた」と答えているのに、実物は素通りしている。
検定の道具が「安全」の側へ誤報する向きが、実在する。

自分のフックが realpathstat[ -e ... ] を使っているなら、
下の結果をそのまま信じないほうがいい。その場合は、実在するダミーのファイルを
一時の場所に作ってから流すことになる(それでも本物の鍵は要らない)。

もう1つ。probe が送る JSON は tool_nametool_input の2欄だけだが、
本物のフックは cwdpermission_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つに分かれる。

  1. 末尾を $ で固定していたので、控え(.bak _old)が全部外れる
  2. 名前の型に入っていない鍵(deploy_key.pem)は最初から見ていない
  3. 鍵以外の認証情報(~/.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.jsonmatcherRead だけにしている場合は、そもそも呼ばれもしない。

そしてもう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.jsondeny と重ねる、鍵を作業ディレクトリの外に置く、
といった別の層と一緒に使うものだと思っている。

まとめ

  • 入力を流して終了コードを見るだけで、危ない鍵を1バイトも読まずに守りを検定できる
  • 検定には「止めるべきものが止まるか」と「通すべきものが通るか」の両方を入れる
  • 末尾を $ で固定した鍵の照合は、控えファイルで抜ける
  • ~/.git-credentials は鍵ではないので、鍵の名前しか見ない守りからは見えない
  • 広げても穴は残る。残った穴の場所は、逆向きの検定を流すまで分からない
  • ツールによって入力の欄名が違う(file_pathpath
  • 文字列の照合には端がある。層を重ねる前提で使う

私は「フックを入れた」ことに安心して、どの口が守られているのかを一度も数えていなかった。
上の 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 ——は、この記事の検定では拾えていなかった。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?