◉CLAUDE LABEN
●2.1.301 — vim の % モーション、VS Code パネルでの LaTeX 数式表示、使用量上限待ちのリトライ監視の上限が入りました(10/15)●2.1.299 — 200K トークン超で約54分放置した会話はアイドル圧縮。MCP の許可ルールは名指ししたサーバーだけに一致します●SONNET 4.5 — Claude API での提供終了は 11/30、残り45日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました●2.1.301 — vim の % モーション、VS Code パネルでの LaTeX 数式表示、使用量上限待ちのリトライ監視の上限が入りました(10/15)●2.1.299 — 200K トークン超で約54分放置した会話はアイドル圧縮。MCP の許可ルールは名指ししたサーバーだけに一致します●SONNET 4.5 — Claude API での提供終了は 11/30、残り45日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました
記事一覧/Claude Code
⟐ Claude Code/2026-09-01中級

heredoc のクォートを1つ落とすと、ログに書いた金額や日付が別物になります

未クォートの heredoc は本文の変数とバックティックを実行します。実測した4通りの書き換わり方、3種類のクォート記法の比較、プレースホルダと sed で値を安全に埋める手順、既存スクリプトを棚卸しする検出コードまでをまとめました。

Claude Code265シェルスクリプトheredoc自動化87トラブルシューティング51

実行ログを開いたら、書いたはずの「コスト: $2 / MTok」が「コスト: / MTok」になっていました。金額だけがきれいに消えています。

隣の行では、記録用に貼っておいたコマンドの文字列が、そのコマンドの実行結果に置き換わっていました。さらに数えてみると、7行書いたはずのログが6行しかありません。

犯人はスクリプトのどこにもいませんでした。強いて言えば、cat << EOF の EOF にクォートが付いていない、その一点です。

直し方だけ先に書きます

区切り語をクォートで囲みます。差分はこれだけです。

# 変更前
cat << EOF > log.txt
コスト: $2 / MTok
EOF
 
# 変更後
cat << 'EOF' > log.txt
コスト: $2 / MTok
EOF

区切り語(この例では EOF)にクォートが付いていない heredoc は、本文をシェルの展開対象として扱います。付いていれば、本文は1文字も解釈されずにそのまま渡ります。

急いでいる方はここで戻っていただいて大丈夫です。以下は、何がどう書き換わるのかを実際に測った記録と、値を埋めたい場合の書き方、既存スクリプトの棚卸し手順です。

未クォートの heredoc が、実際に何を書き換えたか

7行のログを書くつもりで、次のスクリプトを走らせました。DATE にはあらかじめ 2026-09-01 を入れてあります。

DATE="2026-09-01"
cat << EOF > a2.txt
日付: $DATE
生成コマンド: `date -u +%Y-%m-%dT%H:%M`
バックアップ先: /var/backup/$USER/
コスト: $2 / MTok
正規表現: ^\d{4}-\d{2}$
継続行: 末尾のバックスラッシュ \
次の行
EOF

出力された a2.txt は次のとおりでした。

日付: 2026-09-01
生成コマンド: 2026-09-01T06:06
バックアップ先: /var/backup/masaki/
コスト:  / MTok
正規表現: ^\d{4}-\d{2}$
継続行: 末尾のバックスラッシュ 次の行

wc -l は 6 を返しました。7行のつもりが6行です。書いた内容と出た内容を並べます。

書いた内容出力された内容起きたこと
日付: $DATE日付: 2026-09-01変数が展開された(意図どおり)
生成コマンド: バックティックで囲んだ date生成コマンド: 2026-09-01T06:06コマンドが実行され、結果に置換された
/var/backup/$USER//var/backup/masaki/環境変数が展開された
コスト: $2 / MTokコスト: / MTok位置パラメータ $2 として解釈され、空文字になった
正規表現: ^\d{4}-\d{2}$正規表現: ^\d{4}-\d{2}$そのまま残った(後述)
末尾がバックスラッシュの行+次の行1行に連結行継続として食われ、行数が1減った

個人開発で書いたスクリプトを自分でレビューしている身としては、一番怖いのは4行目です。$2 は「2ドル」ではなく「2番目の引数」として読まれ、引数がないので空文字に置き換わりました。エラーは出ません。料金の記録が静かに空欄になるだけです。私が最初に気づいたのもここでした。

2行目も性質が悪いところです。「後で見返すためにコマンドを控えておく」という、ログでは一番よくある書き方が、そのままコマンドの実行になります。書いた覚えのない値が入っているのに、スクリプトを読み返しても実行している箇所が見つかりません。

一方で5行目の \d や末尾の $ は無傷でした。未クォート heredoc でバックスラッシュが効くのは $・バックティック・バックスラッシュ・改行の直前だけで、それ以外は素通りします。「一部は壊れて一部は残る」ので、目視レビューでは見落とします。

クォートの書き方は3通りあり、結果はすべて同じでした

$HOME とバックティックを1行に入れた本文で、区切り語の書き方だけを変えて比べました。

書き方出力
<< EOF/home/masaki|RUN(展開・実行される)
<< 'EOF'そのまま(展開されない)
<< "EOF"そのまま(展開されない)
<< \EOFそのまま(展開されない)

シングルクォート・ダブルクォート・バックスラッシュのどれでも結果は同じです。区切り語の一部でもクォートされていれば、本文全体がリテラルになります。

ここは通常のシェルの感覚と食い違うところです。ダブルクォートは普段なら変数を展開しますが、heredoc の区切り語に限っては「クォートされているか否か」だけが判定されます。私はこの記法を 'EOF' に統一しています。読み手が「ここは展開されるのか」と一瞬でも考えなくて済むからです。既存コードで "EOF" を見かけても直す必要はありません。

<<- を使うとタブによる字下げを剥がせます。剥がれるのはタブだけで、スペースは残りました。エディタがタブをスペースに変換する設定になっていると、この機能は知らぬ間に無効になります。

値を埋めたいときは、プレースホルダを置いて後から差し込む

「変数を展開したいから未クォートにしている」場合もあります。ただ、埋めたい値が2つ3つあるだけなら、本文をリテラルに保ったまま後から差し込むほうが安全です。

DATE="2026-09-01"; SLUG="my-article"
 
cat << 'EOF' > tmpl.txt
日付: __DATE__ / slug: __SLUG__
コスト: $2 / MTok
EOF
 
sed -e "s/__DATE__/${DATE}/" -e "s/__SLUG__/${SLUG}/" tmpl.txt

出力は次のとおりです。

日付: 2026-09-01 / slug: my-article
コスト: $2 / MTok

埋めたい2つだけが埋まり、$2 は文字として残りました。本文に何が入っていても壊れないので、ログのように「中身が事前に読めないテキスト」を書き出すときはこの形にしています。

ただし、この方法には二次災害があります。差し込む値にスラッシュが含まれていると sed が転びます。

P="/var/log/app"
sed "s/__P__/${P}/" <<< "path: __P__"
# => sed: -e expression #1, char 10: unknown option to `s'

s コマンドの区切り文字は自由に選べるので、パスを埋めるときは | などに変えます。

sed "s|__P__|${P}|" <<< "path: __P__"
# => path: /var/log/app

パスを埋める用途では区切りを | に固定しておくのが無難です。値の側に | が来る可能性まで含めて考えるなら、sed ではなく python3 や awk に渡したほうが確実です。

手元のスクリプトを棚卸しする

一度でも踏んだなら、他にも同じ書き方が残っているはずです。未クォートの heredoc のうち、本文に展開対象を含むものだけを拾う小さなスクリプトを書きました。

import re, sys, pathlib
 
OPEN = re.compile(r"<<-?\s*(?P<q>['\"\\])?(?P<w>[A-Za-z_][A-Za-z0-9_]*)")
RISK = re.compile(r"`|\$\(|\$[A-Za-z_{0-9]|\\$")
 
def scan(path):
    lines = pathlib.Path(path).read_text(errors="replace").splitlines()
    hits, i = [], 0
    while i < len(lines):
        m = OPEN.search(lines[i])
        if m and m.group("q") is None:
            word, start = m.group("w"), i
            body = []
            i += 1
            while i < len(lines) and lines[i].strip() != word:
                body.append(lines[i]); i += 1
            risky = [b for b in body if RISK.search(b)]
            if risky:
                hits.append((start + 1, word, len(body), len(risky)))
        i += 1
    return hits
 
total = 0
for p in sys.argv[1:]:
    for ln, w, n, r in scan(p):
        total += 1
        print(f"{p}:{ln}: 未クォート heredoc({n}行中 {r}行に展開対象)")
print(f"検出: {total} 件")
sys.exit(1 if total else 0)

区切り語がクォートされているものは最初から除外し、本文にバックティック・$(・$変数・末尾バックスラッシュのいずれも含まないものも落とします。展開対象がなければ、未クォートでも実害がないためです。

このスクリプトを、手元の Linux 環境にあるシェルスクリプト272本(/usr/bin・/usr/sbin・/etc 配下)に当ててみました。

項目件数
走査したシェルスクリプト272 本
heredoc を含むファイル60 本
検出された未クォート heredoc138 箇所(40 ファイル)
クォート済みの heredoc9 箇所

ここで正直に書いておきたいのは、138箇所の大半はバグではないということです。設定ファイルを生成するスクリプトは変数を展開したくて未クォートにしているので、検出されて当然です。このスクリプトが出すのはバグ一覧ではなく、目を通す価値のあるレビュー対象の一覧です。

それでも役には立ちます。行番号と「本文の何行に展開対象があるか」が並ぶので、設定生成なのかログ出力なのかは開いた瞬間に判断できます。私は自動実行するスクリプトを更新するとき、この出力を一度眺めてから push するようにしました。

同じ「エラーが出ないまま結果だけが変わる」種類の落とし穴は他にもあって、settings.json のキー名を1文字間違えても、Claude Code は何も言わずに無視します で扱った設定キーの誤りも同じ構図でした。シェルの引用符が絡む失敗をもう一段深く追いたい方には、空白を含むパスで一括処理が知らぬ間に0件になる過程を測った空白ひとつで、80件の検査が0件になるが近いところにあります。

なぜ AI に書かせると未クォートで返ってくるのか

今回の元のスクリプトは、私が Claude Code に「ログを書き出す処理を足して」と頼んで返ってきたものでした。返答は素直な cat << EOF です。

理由は測った数字がそのまま示していると考えています。272本のスクリプトに含まれていたクォート済みの heredoc は9箇所、未クォートは138箇所でした。世の中のシェルスクリプトにおける多数派は未クォートで、学習した側がその比率を反映するのは自然なことです。人間が書いても同じ比率になるはずで、これはモデルの欠点というより、シェルの既定値が「展開する」側に倒れていることの帰結です。

なので、頼み方を一言変えるだけで結果が変わります。

ログを書き出す処理を足してください。
heredoc は必ず <<'EOF' の形(区切り語をクォート)で書き、
値を埋める必要がある箇所は __PLACEHOLDER__ を置いて sed で差し込んでください。

この一文を入れてからは、生成されるスクリプトの形が安定しました。プロジェクトの設定ファイルやスキルの手順書に書いておけば、毎回頼まなくても済みます。

今日できる一手は、cat << EOF で始まる行を手元のスクリプトから検索して、ログやメッセージを書き出しているものだけ 'EOF' に変えることです。設定生成のものは触らなくて構いません。それだけで、金額や日付が知らぬ間に書き換わる経路はひとつ閉じます。

私自身、この挙動は何度か読んだことがあったはずなのに、実際にログが1行短くなるまで自分の問題として認識できていませんでした。同じ場所で手を止めた方の役に立てば嬉しいです。お読みいただきありがとうございました。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⟐ Claude Code2026-08-25
settings.json のキー名を1文字間違えても、Claude Code は何も言わずに無視します
キー名を書き間違えた settings.json と正しい settings.json で claude doctor の出力を比べたところ、差分はゼロでした。何が検証され何が素通りするかを5通りで測り、書き間違いを自分で落とす検査を用意します。
⟐ Claude Code2026-08-22
長い処理を別セッションに任せて待つ — 通知が届かなかったときのための完了マーカー
Claude Code の notify_when_idle で別セッションの完了通知を受け取る手順と、通知が落ちても待ち続けないための完了マーカーの最小実装。3つの状態切り分けを実行結果つきで解説します。
⟐ Claude Code2026-10-13
「cd して git add して commit」の承認が毎回出る — 設定の抜けではなく、仕様だと分かるまでにたどった道
cd /path && git add && git commit のような複合コマンドで、許可ルールを入れても承認が繰り返し出る。公式の権限ドキュメントと GitHub の報告を突き合わせると、cd と git の組み合わせは設計上プロンプトが出ると分かりました。原因の切り分け順と、手数を減らす4つの直し方を並べます。