Claude Code 2.1.212 の変更履歴に、一行だけ手を止めた項目がありました。リポジトリにコミットされたシンボリックリンクが .claude/worktrees にあると、worktree の作成時にそれを辿ってリポジトリの外にファイルを作りうる、という不具合の修正です。すでに直っています。
ただ、直ったと知って安心する前に、自分が何を Claude Code に預けているかを思い出しました。個人開発の自動化で、私はいくつかのリポジトリを日々 clone しては worktree を切り、その上で並列に作業を走らせています。clone してくるリポジトリの中身を、シンボリックリンクという観点で一度も見たことがありませんでした。
パッチが塞いだのは「辿り」の側です。ツールが悪意あるリンクを辿らないようにする修正です。けれど「危険なリンクがそもそもコミットされている」側は、リポジトリを扱う私にしか点検できません。パッチは全ての AI ツール・全ての操作を守ってくれるわけではなく、辿りうる経路は worktree だけでもありません。この記事は、その点検を手元でどう回すかの記録です。
git はシンボリックリンクを「行き先の文字列」ごとコミットする
前提を一つ確認します。git はシンボリックリンクを特別扱いせず、中身が「リンクの行き先の文字列」であるファイルとして保存します。ファイルモードだけが通常ファイル(100644)と異なり、120000 になります。
手元のリポジトリで実際に見てみます。
# 危険な例を意図的に作る(検証用。すぐ消します)
ln -s /etc/hosts ./link-to-hosts
git add ./link-to-hosts
# git がどう記録したかを見る
git ls-files -s ./link-to-hosts
# 100644 ではなく 120000 で入る:
# 120000 a1b2c3... 0 link-to-hosts
# blob の中身は「行き先の文字列そのもの」
git cat-file blob :./link-to-hosts
# /etc/hosts
120000 のエントリは、blob の中身が丸ごとリンクの行き先です。/etc/hosts でも ../../../../home/user/.ssh/authorized_keys でも、文字列である限り git は淡々とコミットします。ここに検証はありません。つまり、リポジトリ外を指すリンクは、通常の変更と見分けがつかない形でリポジトリに紛れ込めます。
さらに厄介なのが core.symlinks の存在です。この設定が true(多くの環境の既定)なら、checkout でリンクは本物のシンボリックリンクとして復元されます。false なら、行き先の文字列を中身に持つ通常ファイルとして書き出され、辿られません。同じコミットが、環境設定次第で「辿られるリンク」にも「ただのテキスト」にもなる。この差が、点検を難しくしています。
なぜ worktree の作成で外に出られたのか
シンボリックリンクをまたいだ書き込みが外に出る仕組みは、worktree に限った話ではありません。一般に、あるパスへの書き込みが途中にシンボリックリンクを含んでいると、OS はリンクを辿った先へ書き込みます。a/b/c の b が /tmp/evil へのリンクなら、a/b/c への書き込みは /tmp/evil/c に落ちます。
worktree の作成では、ツールが .claude/worktrees 配下に管理用のファイルを書きます。もしそのパスの一部が、リポジトリにコミットされたリンクとして「リポジトリ外」を指していたら、書き込みはリポジトリの外へ抜けます。checkout の順序次第では、書こうとした瞬間にはリンクが実体として存在している、という時間差も絡みます。いわゆる TOCTOU(Time-of-check to time-of-use)の隙です。
2.1.212 の修正は、この辿りをツール側で止めるものでした。評価に値する対応です。一方で、私が引き受けるべきなのは別の問いでした。「そもそも、そんなリンクが混ざったリポジトリを、自分は AI に触らせていないか」。clone 元が自分の管理下にあるとは限りません。フォークやテンプレート、依存として取り込む外部リポジトリを worktree で開くなら、点検の対象は広がります。
危険なリンクだけを洗い出す監査スクリプト
全てのシンボリックリンクを消す必要はありません。正当なリンク(リポジトリ内の相対リンクなど)は日常的に使われます。洗い出したいのは「リポジトリの外を指すもの」「絶対パスのもの」「.git や .claude の内部を指すもの」だけです。
git ls-files -s で 120000 のエントリを取り、blob の中身(行き先)を正規化して、リポジトリルートの外へ出るものを危険と判定します。ワーキングツリーではなくインデックスを見るので、checkout 前の clone 直後でも走ります。
#!/usr/bin/env bash
# audit-symlinks.sh — コミット済みシンボリックリンクのうち危険なものを列挙する
# 使い方: リポジトリのルートで bash audit-symlinks.sh
set -euo pipefail
ROOT = "$( git rev-parse --show-toplevel )"
DANGER = 0
# 120000(シンボリックリンク)のエントリだけを取り出す
git ls-files -s | awk '$1 == "120000" { $1=$2=$3=""; sub(/^ +/,""); print }' \
| while IFS = read -r path ; do
# blob の中身 = リンクの行き先の文字列
target = "$( git cat-file blob ":${ path }")"
verdict = ""
case " $target " in
/ * ) verdict = "絶対パス" ;; # / から始まる
esac
# リンクのある位置を基準に行き先を正規化し、ルート外なら危険
linkdir = "$( dirname "${ ROOT }/${ path }")"
resolved = "$( cd " $linkdir " 2> /dev/null && \
python3 -c "import os,sys;print(os.path.normpath(os.path.join(os.getcwd(), sys.argv[1])))" " $target " 2> /dev/null || true )"
case " $resolved " in
" $ROOT " | " $ROOT "/ * ) : ;; # ルート配下なら許容
*) [ -n " $verdict " ] || verdict = "ルート外を指す" ;;
esac
# .git / .claude の内部を指すものは無条件で危険
case " $resolved " in
" $ROOT "/.git | " $ROOT "/.git/ *| " $ROOT "/.claude | " $ROOT "/.claude/ * ) verdict = "管理ディレクトリを指す" ;;
esac
if [ -n " $verdict " ]; then
printf '⚠️ %-40s → %s [%s]\n' " $path " " $target " " $verdict "
DANGER = 1
fi
done
exit " $DANGER "
このスクリプトは危険なリンクを 1 件でも見つけたら終了コード 1 を返します。CI やフックで if ! bash audit-symlinks.sh; then … と受けられるようにするためです。実際に手元の 4 リポジトリへ流したところ、危険判定は 0 件でした。0 件を確認できたこと自体が収穫で、「見ていなかった」から「見て問題なかった」へ状態が変わりました。
何を「危険」とみなすか — 判定の基準
判定の線引きを表にまとめます。ここは正当なリンクを巻き込みすぎないためのチューニングどころです。
リンクの行き先 例 判定
絶対パス /etc/hosts危険(環境依存で挙動が読めない)
ルート外への相対 ../../../.ssh/id_ed25519危険(リポジトリの外へ抜ける)
管理ディレクトリ内 ./x → .git/hooks危険(hook 差し込み等に転用されうる)
ルート配下の相対 ./docs/logo.svg許容(正当な用途が多い)
行き先が存在しない dangling link 要確認(意図を確認してから)
一点、注意点を正直に書いておきます。この監査は「コミットされたリンク」を見るものです。実行時に生成されるリンクや、core.symlinks の解決タイミングが絡む TOCTOU 的な隙まではカバーしません。監査は入口を狭める一手であって、辿り自体を止める修正の代わりにはなりません。だからパッチ適用と監査は、片方ではなく両方を回すのが私の考えです。
隔離してから worktree を作る
信頼度の低いリポジトリを開くなら、clone の時点で辿りを回避できます。core.symlinks=false で clone すると、リンクは本物のリンクではなく、行き先の文字列を中身に持つ通常ファイルとして展開され、OS に辿られません。
# 信頼できない clone は、まずリンクを「ただのテキスト」として落とす
git -c core.symlinks= false clone " $REPO_URL " ./untrusted
cd ./untrusted
bash /path/to/audit-symlinks.sh || {
echo "危険なリンクを検出。worktree 作成を中止します。"
exit 1
}
# 監査を通ってから、必要ならリンクを有効化して worktree を切る
信頼できない clone を扱うときの順序は、次の 4 つに固定しています。
外部由来のリポジトリは core.symlinks=false で clone する
audit-symlinks.sh を流し、危険判定が出たら worktree を切らずに止める
通ったら、ビルドに必要なリンクだけを有効化する
その状態で worktree を切り、Claude Code に渡す
core.symlinks=false は正当なリンクにも影響します。リンクを前提にビルドするプロジェクトでは、この設定のままだと動きません。信頼できない clone だと判断したこの場合は、まず封じる側に倒し、通ってから戻す、という順序で使います。私はこの clone を外部由来のリポジトリにだけ適用し、自分の管理下のリポジトリは通常 clone にしています。全部を締めると正当な用途まで壊すので、締める対象を出所で切り分けるのが現実的でした。
Claude Code に worktree を量産させる運用そのものについては、Claude Code worktree で並列開発を加速する で基本の流れを扱っています。本記事はその前段に置く「点検」の層だと考えてください。
clone と CI に点検を差し込む
点検は、思い出したときに手で流すのではなく、経路に固定してこそ効きます。差し込みどころを整理します。
差し込む場所 やること 効き方
clone ラッパー 外部由来は core.symlinks=false で clone 辿りを入口で封じる
worktree 作成の直前 audit-symlinks.sh を実行し、失敗なら中止危険リンクのある枝を開かせない
CI(pull request) 同スクリプトを 1 ジョブとして走らせる 危険リンクの新規混入を弾く
定期実行 扱う全リポジトリへ週次で流す 取り込み済みの見落としを拾う
worktree 作成をラップする最小の形はこうなります。監査に通らなければ、その枝は開きません。
safe_worktree_add () {
local branch = " $1 " dir = " $2 "
bash "$( git rev-parse --show-toplevel )/audit-symlinks.sh" || {
echo "監査に失敗。worktree を作成しません: $dir "
return 1
}
git worktree add " $dir " " $branch "
}
権限の面から無人実行を締める話は無人実行で auto モードの確認要求を止めずに捌く に、破壊的コマンドの事前確認に助けられた記録は空の変数と rm -rf の夜 にまとめてあります。ファイルシステムの経路を締める本記事とは別のレイヤーですが、無人でリポジトリを触らせる運用では両方が要ります。
Before / After — 点検を挟む前と後
変わったのは、コードよりも順序でした。
点検を挟む前 点検を挟んだ後
外部リポジトリの clone 通常 clone のまま worktree へ 出所で分け、外部は封じてから展開
シンボリックリンク 存在すら見ていなかった 危険なものだけを機械的に列挙
worktree 作成 無条件に実行 監査に通った枝だけを開く
新規混入 気づく手段がなかった CI で pull request 時に弾く
追加したスクリプトは数十行、CI ジョブは 1 つで、worktree 運用のコード全体から見れば 1% にも満たない量です。それで「見ていなかった経路」が「見て、通ったら開く経路」に変わりました。実装の重さと、防いでいる事故の重さが釣り合っていない――こういう非対称な投資を見つけられると、少し嬉しくなります。
おわりに
まず一つだけ試すなら、いま AI に触らせているリポジトリのルートで audit-symlinks.sh を一度走らせてみてください。多くの場合、結果は 0 件です。その 0 件は「危険がない」ことの確認であり、「見ていなかった」から一歩進んだ証でもあります。危険が出たら、その 1 件が worktree を切る前に見つかったこと自体が、点検を経路に固定する理由になります。
私自身、セキュリティは詳しいとは言えず、この点検も完全ではありません。それでも、パッチに守ってもらうだけでなく、自分の側でも一枚挟んでおく。その小さな習慣を、共有できればと思って書きました。お読みいただきありがとうございました。