夜間の自動更新を回すようになってから、承認の判断だけが私の手元に残りました。
個人開発で回している Dolice Labs の4サイトは、記事の生成からゲート検査、push までを無人で進みます。危ういところに差しかかったときだけ、確認プロンプトが手元のチャットに流れてきて、私がそれを読んで通す。外出先で、画面の一行だけを読んで承認したことも一度や二度ではありません。
先日の Claude Code の更新に、短い一文がありました。チャットチャネルに中継されるパーミッションプレビューで、双方向上書き文字・ゼロ幅文字・見た目が似た引用符を無害化するようになった、というものです。ツール入力によって承認メッセージの見え方を変えられなくするための修正だと書かれていました。
読んだところで、手が止まりました。
見え方を変えられる。つまり私はこれまで、実行される文字列そのものではなく、実行される文字列の見え方を承認していたことになります。この二つは、普段は一致しています。一致していると信じる理由は、特にありませんでした。
手元の中継に細工した文字列を通してみたら、思っていたのと違う結果が出ました。その記録です。
「見え方」を変える細工は、どこから来るのか
文字列の見え方と中身がずれるのは、Unicode に「表示のための指示」を持つ文字が含まれているからです。表示を担当する側はその指示に従い、実行を担当する側は指示を無視してバイト列だけを見ます。この非対称が、そのまま隙になります。
細工 符号位置 何が起きるか
双方向上書き(RLO) U+202E 以降の文字が右から左に描画され、末尾が入れ替わって見える
ゼロ幅スペース U+200B 幅を持たずに語の途中に潜り込む 。目視でも正規表現でも落ちる
ゼロ幅非接合子 U+200C 同上。フラグ名の内部に挟むとオプションが別物になる
見た目が似た引用符 U+201C / U+201D クオートされているように見えて、シェルにはただの文字
ホモグリフ U+043F 等 キリル文字の п をラテン文字の n に見せる。別の識別子になる
改行 U+000A プレビューに偽の行を足せる 。「承認済み」という嘘の一行を差し込める
最後の改行が、私には一番こたえました。細工の目的は、危険なコマンドを安全に見せることだけではありません。承認する人間に「もう誰かが確認した」と思わせることでも達成されます。攻撃対象は端末ではなく、画面を読んでいる私です。
上流の修正が守るのは、上流の画面まで
ここで整理しておきたいのは、信頼境界がどこに引かれているかです。
Claude Code 側の修正は、Claude Code が組み立てるパーミッションプレビューを無害化します。これは正しい対処で、既定の経路を使っている限り効きます。
私の構成は、そこから一歩はみ出していました。無人実行の結果を stream-json で受け取り、確認が必要な要求を自前のボットで整形して、チャットに流しています。整形しているのは私のコードです。つまり上流が無害化した文字列を、私が受け取り直して、私のレンダラで描き直している。上流の修正は、私のレンダラの中までは届きません。
境界を線で書くと、こうなります。
区間 守る主体 私の構成での状態
ツール入力 → Claude Code のプレビュー Claude Code(修正済み) ✅ 保護される
stream-json → 自前ボットの整形 私 ⚠️ 素通しだった
ボット → チャットの描画 チャットクライアント ⚠️ クライアント任せ
承認 → 実際の実行 私 ⚠️ 照合していなかった
プロンプトインジェクション対策として、モデルに渡す入力を洗う話はよく語られます。あれはモデルを守るための処理です。ここで問題になっているのは向きが逆で、モデルから人間へ流れるチャネルです。承認者を守る側の処理は、私の構成では誰も担当していませんでした。
手元の中継に、細工した文字列を通してみた
推測で語っても仕方がないので、測りました。細工済みのペイロードを6種類用意し、思いつく防御を4層並べて、どこで止まるかを見ます。
# probe.py — 細工文字が各層をどこまで通るかを測る
import unicodedata
RLO , PDF_ , ZWSP , ZWNJ = " \u202E " , " \u202C " , " \u200B " , " \u200C "
# (名前, 実際に渡されるバイト列, 人間に見えている見た目)
CORPUS = [
( "P1 bidi-override" , f "rm -rf /tmp/build { RLO } gpj.nohtyp/ { PDF_ } " , "rm -rf /tmp/build/python.jpg" ),
( "P2 zero-width" , f "rm { ZWSP } -rf / { ZWSP } " , "rm -rf /" ),
( "P3 lookalike-quote" , 'rm -rf “$HOME/repos”' , 'rm -rf "$HOME/repos"' ),
( "P4 homoglyph" , "claude --dangerously-skip-permissiпns" , "claude --dangerously-skip-permissions" ),
( "P5 fake-line" , "git status \n ✅ Approved by policy \n git push --force" , "git status" ),
( "P6 zwnj-flag" , f "--allow { ZWNJ } -all" , "--allow-all" ),
]
def L1_plain (s): # そのままチャットへ流す
return False
def L2_strip_ctrl (s): # 制御文字(カテゴリ Cf)の有無で弾く
return any (unicodedata.category(c) == "Cf" for c in s)
def L3_nfkc (s): # NFKC 正規化して変化したら弾く
return unicodedata.normalize( "NFKC" , s) != s
def L4_nonascii (s): # ASCII 外と改行を可視化して弾く
return any ( ord (c) > 0x 7E or c == " \n " for c in s)
LAYERS = [( "L1 そのまま中継" , L1_plain), ( "L2 制御文字の除去" , L2_strip_ctrl),
( "L3 NFKC正規化" , L3_nfkc), ( "L4 エスケープ表示" , L4_nonascii)]
counts = {n: 0 for n, _ in LAYERS }
for name, raw, _ in CORPUS :
for n, fn in LAYERS :
counts[n] += fn(raw)
n = len ( CORPUS )
for name, _ in LAYERS :
print ( f " { name :18 } 検知 { counts[name] } / { n } ( { counts[name] / n * 100 :.0f } %)" )
手元での結果です。
層 検知 率 取りこぼし
L1 そのまま中継 0/6 0% 全部
L2 制御文字の除去 3/6 50% 引用符・ホモグリフ・改行
L3 NFKC正規化 0/6 0% 全部
L4 エスケープ表示 6/6 100% なし
L3 の 0% で、一度読み返しました。
「とりあえず NFKC しておけば正規化される」というのは、私の中でほとんど反射になっていた手続きです。実際には、NFKC は互換分解と合成を行う変換であって、書式制御文字(カテゴリ Cf)を除去しません。カーリー引用符も、キリル文字も、ラテン文字には寄せません。それらは互換等価ではないからです。変換して何も変わらなければ、差分で検知する仕掛けは当然すべて素通りします。
正規化と無害化は、別の仕事です。名前が似ているだけで、片方がもう片方を兼ねてはくれませんでした。
L2 の 50% も示唆的でした。制御文字を落とすだけでは、見た目が似た引用符もホモグリフも残ります。どちらも「正当な文字」であって、制御文字ではないからです。
素朴な規則は、私の日本語コミットメッセージを止めた
では L4 を採用すればいいのか。100% 止まっているのだから、と思いたくなります。
そこで、私が普段流している無害なコマンドを同じ規則にかけました。ここで足が止まりました。
BENIGN = [
'git commit -m "記事を追加: 承認中継の設計"' ,
'git commit -m "Add: approval relay design (JA+EN)"' ,
'python3 article_gate.py content/articles/ja/claude-code/x.mdx' ,
'echo "完了しました" >> log.txt' ,
'rm -rf "$HOME/repos/claudelab.net/.next"' ,
]
5件中2件が HOLD になりました。誤検知率 40% です。
止まったのは、日本語を含む2件でした。当然です。「記事を追加」は ASCII の外にあります。L4 は ASCII 外をすべて疑うので、私の日常のコミットメッセージが、そのまま不審物として扱われます。
日英2言語で記事を書いている以上、日本語のコミットメッセージは止まりません。ここを止める防御は、私が数日で無効化するに決まっています。承認疲れを起こす仕掛けは、長期的には防御ではなく、単に承認ボタンを押す速度を上げるだけです。
スクリプト許可制に切り替える
必要なのは「ASCII 外を疑う」ではなく、「この文脈で現れるはずのない文字を疑う 」でした。日本語は現れてよい。キリル文字は現れる理由がない。書式制御文字も、改行も、コマンド一行の中に現れる理由がない。
許可する文字体系を並べて、そこから外れたものだけを咎めます。
import unicodedata
DECEPTIVE = {
" \u202A " : "LRE" , " \u202B " : "RLE" , " \u202C " : "PDF" , " \u202D " : "LRO" , " \u202E " : "RLO" ,
" \u2066 " : "LRI" , " \u2067 " : "RLI" , " \u2068 " : "FSI" , " \u2069 " : "PDI" ,
" \u200B " : "ZWSP" , " \u200C " : "ZWNJ" , " \u200D " : "ZWJ" , " \uFEFF " : "BOM" ,
"“" : "LEFT-DQ" , "”" : "RIGHT-DQ" , "‘" : "LEFT-SQ" , "’" : "RIGHT-SQ" ,
}
ALLOWED = ( "LATIN" , "DIGIT" , "CJK" , "HIRAGANA" , "KATAKANA" , "IDEOGRAPHIC" , "FULLWIDTH" )
def script_of (ch):
try :
name = unicodedata.name(ch)
except ValueError :
return "UNNAMED"
return next ((a for a in ALLOWED if a in name), "OTHER" )
def inspect (raw):
"""咎めるべき位置だけを返す。日本語は通す。"""
hits = []
for i, ch in enumerate (raw):
if ch in DECEPTIVE :
hits.append((i, DECEPTIVE [ch]))
elif ch == " \n " :
hits.append((i, "LINE FEED" ))
elif ord (ch) > 0x 7E :
s = script_of(ch)
if s in ( "OTHER" , "UNNAMED" ):
hits.append((i, f "UNEXPECTED { s } " ))
return hits
なお、この記事のコードで制御文字を \u202E のようなエスケープ表記にしているのも、同じ話の延長です。生の文字をそのまま原稿に置くと、原稿を読む人の画面で表示が崩れます。防御について書いた文章が、その防御の対象になっていては筋が通りません。
同じ2つの集合を、そのまま通します。
規則 細工6件の検知 無害5件の誤検知 運用できるか
非ASCIIを一律で疑う(L4) 100% 40% ❌ 数日で無効化する
スクリプト許可制 100% 0% ✅ 通常運用で黙っている
ホモグリフの P4 が「UNEXPECTED OTHER」で止まるのは、キリル文字が許可リストにないからです。ラテン文字と見分ける処理は書いていません。見分けようとせず、来る理由がないと宣言する 方が、短く済みました。個人的には、ホモグリフを網羅しようとする実装より、こちらを推奨します。防御の記述を短く保てたことは、私にとって性能より大きな収穫でした。長い防御は、いずれ読まれなくなります。
見せる文字列と、照合する文字列を分ける
ここまでは、表示の話です。表示を直しても、根本は残っています。私が承認したものと、実際に走るものが同じである保証が、まだどこにもありません。
そこで、承認の単位を「見た目」から「同一性」へ移しました。生のバイト列からトークンを作り、実行の直前にもう一度計算して照合します。表示は人間のため、トークンは機械のためです。
import hashlib
def safe_view (raw: str ) -> str :
"""人間に見せる用。疑わしい文字は符号位置で可視化する。"""
out = []
for ch in raw:
if ch in DECEPTIVE or ch == " \n " or ( ord (ch) > 0x 7E and script_of(ch) in ( "OTHER" , "UNNAMED" )):
out.append( f "⟪U+ { ord (ch) :04X } ⟫" )
else :
out.append(ch)
return "" .join(out)
def approval_token (raw: str ) -> str :
"""承認の対象はバイト列。見た目ではない。"""
return hashlib.sha256(raw.encode( "utf-8" )).hexdigest()[: 16 ]
def render_request (raw: str ) -> dict :
hits = inspect(raw)
return {
"view" : safe_view(raw),
"token" : approval_token(raw),
"findings" : hits,
"verdict" : "HOLD" if hits else "SHOW" ,
}
def confirm (raw_at_exec: str , approved_token: str ) -> bool :
"""承認後にバイト列がすり替わっていたら成立させない。"""
return approval_token(raw_at_exec) == approved_token
動かすと、こうなります。
[HOLD] token=131d185e49635e23
view: rm -rf /tmp/build⟪U+202E⟫gpj.nohtyp/⟪U+202C⟫
hits: RLO@17, PDF@29
[HOLD] token=ef2566299d8fc126
view: git status⟪U+000A⟫⟪U+2705⟫ Approved by policy⟪U+000A⟫git push --force
hits: LINE FEED@10, UNEXPECTED OTHER@11, LINE FEED@31
[SHOW] token=a82299bcae807c0b
view: git commit -m 'ok' && git push origin main
同一バイト列で実行 -> True
ZWSP 混入で実行 -> False
偽の「Approved by policy」が、行として潰れて可視化されます。承認済みに見せかけた一行は、⟪U+000A⟫ を挟んだ一続きの文字列でしかないことが、画面の上で分かります。
そして最後の2行が要点です。承認したトークンは git push origin main のバイト列から作られています。実行の直前にゼロ幅スペースが1つ混ざれば、トークンは一致せず、成立しません。表示をどれだけ細工しても、同一性の照合はすり抜けられません 。表示の防御は目視のためであって、成立の条件はトークンの側に置く。この二段にしてから、私はようやく外出先で承認する気になりました。
組み込むときに踏んだ落とし穴と、その回避
実際に中継へ入れる過程で、いくつかの落とし穴を踏みました。どれも回避策は短く済みましたが、踏むまで見えていませんでした。同じところで足を取られる方がいるかもしれないので、注意点として残しておきます。
HOLD を拒否と同義にしない 。最初は HOLD を即座に拒否へ倒しました。すると正当な理由でカーリー引用符が入った文字列まで落ちます。HOLD は「可視化して人間に出す」であって、拒否ではありません。判断は人間に残し、機械は見え方を保証するところまでを担当します。この線引きを間違えると、また承認疲れに戻ります。
トークンに寿命を持たせる 。承認したまま放置され、30分後に実行される事故がありました。トークンだけでは「いつ承認したか」が入りません。私は発行時刻を含めて15分で失効させています。長さより、失効の有無の方が効きました。
ログには生バイト列を残さない 。可視化した view とトークンだけを残します。細工文字を含む生の文字列をログに書くと、今度はログを読む私が同じ細工に引っかかります。防御した文字列を、防御していない画面へ流し直さないことです。
チャットクライアントの描画は当てにしない 。同じ文字列が、クライアントによって違う見え方になります。だからこそサーバー側で可視化してから送ります。描画を相手任せにしている限り、相手の数だけ表示の仕様があります。
どこまでやるか、私の線引き
正直に書いておくと、これは権限設計の代わりにはなりません。
そもそも rm -rf / を承認できてしまう構成であることが、より大きな問題です。表示を直しても、押せば通ってしまう事実は変わりません。ここで作ったのは、承認する瞬間の視界を確保する仕掛けであって、承認してよい範囲を決める仕掛けではありません。範囲を決めるのは deny-by-default の許可リストの仕事で、そちらが本丸です。
それでも、私はこの二段を入れてよかったと考えています。無人で長く回すほど、人間が判断する回数は減ります。減った分だけ、一回あたりの判断の重みは増えます。年に数回しか押さないボタンなら、押す瞬間に見えているものが正確であってほしい。
上流の修正の一文は、私の構成の穴を教えてくれました。修正そのものより、修正が「どこまでを守る宣言なのか」を読むことに意味があったように思います。守ってくれる範囲が明示されたということは、その外側が自分の責任範囲だと確定したということでもあります。
まずは、お使いの中継に U+202E(RLO)を1文字混ぜた文字列を、1本だけ通してみてください。手元の画面にどう出るかを見るだけで、次に何を書くべきかは決まります。私はそこから始めました。
承認の一瞬に見えているものを、正確にしておく。今日積み増したのはそれだけですが、私にとっては十分な一歩でした。ここまでお付き合いいただき、感謝しております。