同じ名前に見えるディレクトリを6つ作って ls に流したとき、私は自分の目をあまり信用しなくなりました。並んだ6行のうち、本当に claude-helper だったのは1行だけです。残りの5行は、幅も文字数もほぼ同じに見えるのに、中身のバイト列が違っていました。
きっかけは 8月26日にリリースされた Claude Code v2.1.247 です。プラグインマーケットプレイスに関する変更として、制御文字や不可視文字を含む名前を拒否するようになったと Claude Code の changelog に書かれていました。読んだときは地味な項目だと思ったのですが、では自分の環境ではどうやって確かめるのか、という問いには答えられませんでした。手を動かして確かめたので、その過程を残しておきます。
個人開発で使うプラグインやスキルは、たいてい自分ひとりで入れて自分ひとりで使います。レビューしてくれる同僚がいない環境ほど、目視以外の確認手段を1つ持っておく価値があると考えています。
一覧では6つの名前がほとんど区別できませんでした
まず、紛らわしい名前を意図的に6つ作りました。すべて私が検証のために自分で作ったもので、どこかから拾ってきた実物ではありません。
| 種類 | 混入している文字 | 文字数 | UTF-8 バイト数 |
|---|---|---|---|
| 素の名前 | なし | 13 | 13 |
| ゼロ幅スペース | U+200B | 13 | 15 |
| ゼロ幅接合子 | U+200D | 14 | 16 |
| 右横書きオーバーライド | U+202E / U+202C | 18 | 22 |
| キリル文字の a | U+0430 | 13 | 14 |
| ソフトハイフン | U+00AD | 13 | 14 |
注目していただきたいのは、ゼロ幅スペース入りの名前が素の名前と同じ13文字である点です。文字数を数えても差が出ません。差が出るのはバイト数だけで、13 バイトに対して 15 バイトあります。
ls の出力はこうなりました。行の順序は環境によって変わりますが、見た目の情報量はこれで全部です。
claude-helper
claude-hel[U+200D]per
claude-[U+202E]resu-daer[U+202C]
claude[U+00AD]helper
claude[U+200B]helper
clаude-helper
角括弧で囲んだ部分は、この記事のために私が入れた印です。実際のターミナルには何も表示されません。つまり本物の画面では、6行が縦にほとんど同じ形で並ぶだけです。記事に貼るときですら印を足さないと伝わらない、という事実そのものが、この問題の性質をよく表していると思います。
ここから元の名前を復元するのは、画面を眺めているだけでは無理です。私も無理でした。ところが同じ一覧を cat -v に通すと、状況が一変します。
$ ls -A d | cat -v
claude-helper
claude-helM-bM-^@M-^Mper
claude-M-bM-^@M-.resu-daerM-bM-^@M-,
claudeM-BM--helper
claudeM-bM-^@M-^Khelper
clM-PM-0ude-helperM-bM-^@M-^K は U+200B の UTF-8 表現(E2 80 8B)です。cat -v は非表示文字をこの記法に置き換えるので、目で見えない差分がそのまま行に現れます。ターミナルで怪しい名前に出くわしたとき、最初に通すコマンドとして覚えておくと便利です。
v2.1.247 が塞いだ範囲と、手元に残る仕事
changelog に書かれている内容を、私なりに整理するとこうなります。
- マーケットプレイス側で、制御文字や不可視文字を含む名前が拒否されるようになりました
/pluginとclaude pluginの出力に含まれるマーケットプレイス由来のテキストがエスケープされるようになりました- レンダリングされた Markdown のハイパーリンクのうち、ネットワークパスやオートマウンタパスを指すもの、制御文字を含むもの、不可視文字で始まるものはプレーンテキストとして表示されます
つまり、名前そのものと、その名前が画面に表示される経路の両方が塞がれた形です。よくできた対処だと思います。
ただ、これは Claude Code が読み込む段階での防御です。手元のディスクに置かれたディレクトリ名や、自分で書いた設定ファイル、GitHub から git clone してきたリポジトリの中身までを見てくれるわけではありません。私が確かめたかったのはそちらでした。
導入前に走らせる、2つの点検関数
用意したのは短いシェル関数です。.bashrc や .zshrc に貼っておいて、新しいプラグインやスキルを入れた直後に走らせています。
# 制御文字・不可視文字を含む名前を検出する
scan_names() {
local dir="${1:?usage: scan_names <dir>}"
local names hits
names=$(cd "$dir" && ls -A) || return 2
hits=$(printf '%s\n' "$names" \
| grep -nP '[\x00-\x1F\x{00AD}\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{206F}\x{FEFF}]')
if [ -n "$hits" ]; then
printf '%s\n' "$hits" | cat -v
echo "NG: 不可視文字または制御文字を含む名前が $(printf '%s\n' "$hits" | wc -l) 件あります"
return 1
fi
echo "OK: 不可視文字・制御文字は見つかりませんでした"
}対象は、制御文字(U+0000〜U+001F)、ソフトハイフン(U+00AD)、ゼロ幅文字と方向制御文字(U+200B〜U+200F、U+202A〜U+202E)、一般句読点の書式文字(U+2060〜U+206F)、そして BOM(U+FEFF)です。grep -P の \x{...} 記法は PCRE のもので、GNU grep が --perl-regexp 付きでビルドされている必要があります。macOS 標準の grep では使えないので、brew install grep で入れた ggrep に置き換えるか、後述の Python 版を使ってください。
先ほどの6件に当てた結果です。
$ scan_names d
2:claude-helM-bM-^@M-^Mper
3:claude-M-bM-^@M-.resu-daerM-bM-^@M-,
4:claudeM-BM--helper
5:claudeM-bM-^@M-^Khelper
NG: 不可視文字または制御文字を含む名前が 4 件あります
$ echo $?
16件のうち4件を拾いました。素の名前が通るのは正しい挙動です。問題は、もう1件が素通りしていることです。
キリル文字の a は、この点検では拾えません
拾えなかったのは clаude-helper です。3文字目の а がラテン文字の a(U+0061)ではなくキリル文字の а(U+0430)になっています。これは不可視文字でも制御文字でもなく、独立した1つの表示可能な文字なので、先ほどのパターンには一致しません。
$ python3 -c "
a='claude-helper'; b='clаude-helper'
print('文字列として一致:', a == b)
import unicodedata
print('NFKC 正規化後に一致:', unicodedata.normalize('NFKC',a) == unicodedata.normalize('NFKC',b))
"
文字列として一致: False
NFKC 正規化後に一致: FalseUnicode 正規化をかけても同一にはなりません。NFKC は互換文字を畳み込む正規化ですが、キリル文字とラテン文字は別の文字として設計されているので、当然といえば当然です。混同されやすい文字を網羅的に潰そうとすると、Unicode の混同可能文字表を持ち込む話になり、個人の点検スクリプトとしては重くなります。
そこで私はこの点検の線を、もっと手前に引き直すことにしました。プラグインやスキルの名前は印字可能な ASCII だけで書かれているはず、という前提を置いて、そこから外れたものを一律で報告します。
# 印字可能 ASCII 以外を含む名前を検出する(ホモグリフを含めて拾える)
scan_ascii_only() {
local dir="${1:?usage: scan_ascii_only <dir>}"
local hits
hits=$(cd "$dir" && ls -A | LC_ALL=C grep -n '[^ -~]')
if [ -n "$hits" ]; then
printf '%s\n' "$hits" | cat -v
echo "NG: ASCII 以外の文字を含む名前が $(printf '%s\n' "$hits" | wc -l) 件あります"
return 1
fi
echo "OK: すべて印字可能な ASCII の範囲でした"
}[^ -~] は、半角スペース(0x20)からチルダ(0x7E)までの範囲に入らない文字、という意味です。LC_ALL=C を付けているのは、ロケールによって文字クラスの解釈が変わるのを避けるためです。
$ scan_ascii_only d
2:claude-helM-bM-^@M-^Mper
3:claude-M-bM-^@M-.resu-daerM-bM-^@M-,
4:claudeM-BM--helper
5:claudeM-bM-^@M-^Khelper
6:clM-PM-0ude-helper
NG: ASCII 以外の文字を含む名前が 5 件あります今度は5件すべてを拾いました。日本語のディレクトリ名を混ぜている環境では誤検知が増えますが、プラグインやスキルの置き場に限れば、この単純さのほうが実用的だと感じています。判断を細かくするより、線を1本引いて例外を目で確認するほうが、続けられる点検になります。
手元の 906 件に当てて、0件だったこと
書いた以上は自分の環境に当てないと意味がないので、プラグインとスキルの置き場をまとめて走査しました。ディレクトリとファイルを合わせて 906 件、うち最長の名前は 43 文字でした。
結果は、不可視文字・制御文字が 0 件、ASCII 以外の文字を含む名前も 0 件です。
何も出ませんでした。記事にする材料としては拍子抜けする結果で、私自身、少し肩透かしを食らった気分になりました。それでもこの点検を残しているのは、0 件だと確認できている状態と、確認していない状態は別物だからです。前者は次に何かが増えたときに差分として現れますが、後者はいつまでも増えたことに気づけません。
もう一点、find を使う版を最初に書いたのですが、-printf は GNU findutils 固有のオプションで macOS では使えませんでした。上に載せた関数が ls -A を使っているのはそのためです。手元で動いたコードをそのまま配るときに、いちばん踏みやすい種類の段差だと思っています。
次に試すこと
まず、自分のプラグインとスキルの置き場に scan_ascii_only を1回だけ当ててみてください。0件なら、その事実を控えておけば十分です。1件でも出たら、cat -v の行を読んで、その文字が意図して入れたものかどうかを確かめる。それだけで、目視では埋まらない穴が1つ塞がります。
この点検を毎回手で走らせるのが面倒になってきたら、フックに常駐させる段階です。どのタイミングのフックに何を置くかは、Claude Code Hooks — 8種類のフックを使い分ける設計判断と落とし穴 に、8種類それぞれの向き不向きと私が踏んだ落とし穴をまとめてあります。
最後までお読みいただきありがとうございました。小さな確認が積み重なることで安心して手を動かせるようになる、という感覚を共有できていれば嬉しく思います。