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?

Claude CodeのEditが「別の行」を静かに書き換える——カーリー引用符が混ざると起きるデータ破損(#68962)と防ぎ方

0
Posted at

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_allfalse で実行します。
2.1.179での結果:曖昧だという警告は出ずmessage1 のほうが静かに書き換わりました。しかも、hello の周りの引用符のバイトまで、逆向きの丸引用符からふつうの丸引用符へ変わっていました。狙ったトークンの外側の文字まで変異したのです。
静かに別の行を書き換える事故は、あとから気づけないのが本質です。大きな差分のレビューで、1行だけ別の対象に当たってしまった編集は見落とします。とくにMarkdown、ドキュメント、文章、多言語の文字列のカタログでは、丸まった引用符(スマートクォートの自動変換、コピーした文章、翻訳の原文)が日常的に混ざるので、当たりやすい場所です。
根っこの原因は提供側にあり、一意性の確認を一致の判定と同じ基準(正規化後)でやれば直ります。それを待つあいだ、手元では次の2つの習慣で防げます。

  1. old_string に周りの文脈を多めに含めて、ファイルの中で一意にする。 短い引用符だけの断片で指定しない。行ごと、あるいは近くの一意な語まで含める。これで正規化のあいまいさを丸ごと避けられます。
  2. 全部を変えたいときは replace_alltrue にする。
    自動で止めたい場合は、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の誤編集も、データ消失や費用の暴発と同じ「静かな失敗」の仲間です。同じ系統の事故と、その利用者の側での確実な復旧と予防を、実際に踏んだ記録としてまとめた手引きがあります。

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?