Claude CodeでMarkdownやドキュメント、文章、多言語の文字列を編集していると、エラーも出さずにファイルを壊す事故があります。編集自体は「成功しました」と返るのに、書き換わったのが狙っていない別の行だった、という静かな失敗です。
2026年6月17日にGitHubのIssue(#68962)として報告されたこの挙動を、自分の手元の2.1.179で再現して確かめました。報告者は内部の動きを推測で書いていましたが、ここでは実際に再現できた事実だけを書きます。
Edit ツールは、old_string を本文に当てるとき、引用符を正規化して一致させます。つまり、まっすぐな引用符 " ' と、丸まった引用符 “ ” ‘ ’ を同じものとして扱います。ここまでは便利な機能です。
問題は、その後の「一意かどうか」の確認です。一意性のチェックは、正規化する前の生の old_string の出現回数で数えています。一致の判定は正規化後、一意性の確認は正規化前。このずれが事故の正体です。
ファイルの中に丸まった引用符が混ざっていると、まっすぐな引用符で書いた old_string が、一意性のチェックは通るのに、正規化したうえでは複数の場所に当てはまることが起こります。すると Edit は、エラーを出さずに最初の1つを書き換えます。それが狙った行とは限りません。
only = “greeting”
この行に対して、まっすぐな引用符で指定します。
{ "old_string": "\"greeting\"", "new_string": "\"farewell\"", "replace_all": false }
grep -c '"greeting"' は0です(生のまっすぐな引用符は1つも無い)。それでも Edit は成功し、この行を書き換えます。正規化によって一致しているからです。
message1 = ”hello“ # 逆向きの丸引用符
message2 = “hello” # ふつうの丸引用符
どちらの行も、まっすぐな "hello" には厳密には一致しません(grep -c '"hello"' は0)。ここで old_string をまっすぐな "hello"、replace_all を false で実行します。
2.1.179での結果:曖昧だという警告は出ず、message1 のほうが静かに書き換わりました。しかも、hello の周りの引用符のバイトまで、逆向きの丸引用符からふつうの丸引用符へ変わっていました。狙ったトークンの外側の文字まで変異したのです。
静かに別の行を書き換える事故は、あとから気づけないのが本質です。大きな差分のレビューで、1行だけ別の対象に当たってしまった編集は見落とします。とくにMarkdown、ドキュメント、文章、多言語の文字列のカタログでは、丸まった引用符(スマートクォートの自動変換、コピーした文章、翻訳の原文)が日常的に混ざるので、当たりやすい場所です。
根っこの原因は提供側にあり、一意性の確認を一致の判定と同じ基準(正規化後)でやれば直ります。それを待つあいだ、手元では次の2つの習慣で防げます。
-
old_stringに周りの文脈を多めに含めて、ファイルの中で一意にする。 短い引用符だけの断片で指定しない。行ごと、あるいは近くの一意な語まで含める。これで正規化のあいまいさを丸ごと避けられます。 -
全部を変えたいときは
replace_allをtrueにする。
自動で止めたい場合は、PreToolUseのhookで、一意性の確認を一致の判定と同じ正規化後の基準でやり直し、危ない場合だけ止める方法があります。「正規化後の一致数が、生の一致数より多く、かつ2件以上」のときだけ止めれば、ふつうの重複(提供側のツールがすでに弾く)は黙って通せるので、誤検知を狭められます。
HOOK_JSON=$(cat); export HOOK_JSON
python3 <<'PY'
import os, sys, json
try:
data = json.loads(os.environ.get("HOOK_JSON", ""))
except Exception:
sys.exit(0)
ti = data.get("tool_input", {}) or {}
edits = ti.get("edits")
if not isinstance(edits, list):
edits = [ti]
fp = ti.get("file_path")
if not fp:
sys.exit(0)
try:
content = open(fp, "r", encoding="utf-8", errors="replace").read()
except Exception:
sys.exit(0)
TBL = {ord(c): '"' for c in "“”„‟"}
TBL.update({ord(c): "'" for c in "‘’‚‛"})
def norm(s): return s.translate(TBL)
nc = norm(content)
for e in edits:
old = e.get("old_string")
if not old or e.get("replace_all"):
continue
raw = content.count(old)
nrm = nc.count(norm(old))
if nrm > raw and nrm >= 2:
sys.stderr.write(
f"BLOCKED: old_string is ambiguous under quote-normalization "
f"({raw} exact vs {nrm} normalized). It may silently edit the wrong "
f"line (#68962). Add surrounding context, or set replace_all true.\n")
sys.exit(2)
sys.exit(0)
PY
このhookは実機で9通りの場合を確かめて、全部期待どおりに動きました。MITで無料の安全hook集 cc-safe-setup に、テストつきで入っています。
私は800時間以上Claude Codeを自律で走らせるなかで、こういう「成功と言いながら静かに壊す」事故を何度も踏んできました。今回のEditの誤編集も、データ消失や費用の暴発と同じ「静かな失敗」の仲間です。同じ系統の事故と、その利用者の側での確実な復旧と予防を、実際に踏んだ記録としてまとめた手引きがあります。
- 無料で試せる安全hook集(このhookも収録): cc-safe-setup
- 事故の全体像と予防を体系的にまとめた手引き(第3章まで無料試し読み): Anthropic公式ガイドにない事故防止——Claude Code 800+時間の全記録(¥800)
引用符のような小さな違いひとつで、別の行が静かに壊れます。old_stringには周りの文脈を多めに——それだけでも、今日から防げます。