Claude Media
Claude CodeでBashをtrapとset -euo pipefailで堅牢にする手順

Claude CodeでBashをtrapとset -euo pipefailで堅牢にする手順

Claude Codeに書かせたBashスクリプトが途中で失敗しても一時ファイルを残さないよう、set -euo pipefailとtrap EXITを入れる手順です。失敗を注入して後始末を検証し、hookでの落とし穴も扱います。

Claude Codeに「一時ディレクトリで作業して、終わったら消すスクリプトを書いて」と頼むと、mktemp -d と rm -rf が入ったBashスクリプトが出てきます。途中のコマンドが失敗したとき、その rm -rf まで到達するかどうかは、先頭の set -euo pipefail と trap で決まります。

この記事では、依頼文の書き方、失敗を意図的に起こして後始末を確かめる方法、そして set -e が効かない場所を順に扱います。コマンドの出力はmacOSのbash 3.2.57で実行したものです。

何も付けないスクリプトは、失敗しても最後まで走る

まず、保護のないスクリプトの挙動を見ます。途中の false で失敗させています。

#!/usr/bin/env bash
tmp=$(mktemp -d)
echo "tmp=$tmp" >&2
echo data > "$tmp/work.txt"
false
echo "after failure: still running"
bash bad.sh; echo "exit=$?"
tmp=/tmp/tmp.XXXXXXXXXX
after failure: still running
exit=0

失敗した後も処理が続き、終了コードは0です。しかも一時ディレクトリは消えずに残ります。呼び出し側のCIやhookは、成功したと受け取ります。

set -euo pipefailとtrap EXITが受け持つ範囲

Claude Codeに入れさせたい骨格は次の6行です。

#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp -d)
cleanup() { rc=$?; rm -rf "$tmp"; echo "cleanup rc=$rc" >&2; }
trap cleanup EXIT

役割は次のように分かれます。

記述受け持つ範囲
set -e受け持つ範囲コマンドが非0で終わったらスクリプトを止める
set -u受け持つ範囲未定義の変数を参照したら止める
set -o pipefail受け持つ範囲パイプラインのどこかが失敗したら、パイプライン全体を失敗にする
trap cleanup EXIT受け持つ範囲正常終了、失敗終了、exit のどれでも関数を1回走らせる

set -e が止めるだけでは後始末はされません。止まる瞬間に走る処理を trap ... EXIT で用意して、はじめて一時ファイルが消えます。

Claude Codeへの頼み方とCLAUDE.mdの規約

スクリプトの冒頭に何を置くかは、毎回の依頼文に書くより CLAUDE.md に1回書くほうが漏れません。たとえば次のような断片です。

## シェルスクリプトの規約
- 先頭は `#!/usr/bin/env bash` の次の行に `set -euo pipefail`
- `mktemp` を使うスクリプトは、必ず `trap cleanup EXIT` で削除する
- `cleanup` の先頭で `rc=$?` を保存する
- 書いたら失敗を1か所注入して実行し、後始末の出力まで読む

最後の1行が検証の指示です。公式のベストプラクティスは、Claudeが「できた」と判断する材料を持たないと、人間がテストスイートの代わりになると説明しています。合否が返ってくる確認手段を渡せば、Claudeは変更、実行、出力の読み取り、修正の順で自分で回せます。

ただし CLAUDE.md は守られる保証のある仕組みではなく、依頼文と同じ「お願い」です。例外のない規則はhookで強制する、という使い分けは公式の同じページにあります。

途中失敗を注入して、後始末を検証する

後始末は、成功する実行では一度も走りません。壊れていても気づけないので、失敗を作って確かめます。

#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp -d)
cleanup() { rc=$?; rm -rf "$tmp"; echo "cleanup rc=$rc tmp_exists=$([ -d "$tmp" ] && echo yes || echo no)" >&2; }
trap cleanup EXIT
echo data > "$tmp/work.txt"
false
echo "unreachable"
bash good.sh; echo "exit=$?"
cleanup rc=1 tmp_exists=no
exit=1

3点が同時に読み取れます。false の後の行は実行されず、cleanup が走って一時ディレクトリが消え、終了コードは元の失敗の1が保たれています。

Claude Codeに頼むなら、次のように検証の手順まで書きます。

backup.sh の途中で失敗したときに一時ディレクトリが残らないか確認して。
cp の対象を存在しないファイルに変えて実行し、
stderr の出力と終了コードを貼ってから、元に戻して。

出力と終了コードを貼らせる点が肝心です。「後始末は動きます」という報告だけでは、読み手に判断材料がありません。

落とし穴

cleanup の先頭で $? を保存しないと終了コードが 0 に化ける

cleanup の中で rm -rf や echo が成功すると、関数の最後のコマンドの終了コード(0)で関数が終わります。rc=$? を先頭で保存し、必要なら末尾で exit "$rc" と明示します。保存を忘れると、失敗したスクリプトを呼び出し側が成功と読む危険があります。

set -eが効かない場所を先に知っておく

set -e は万能ではありません。Claude Codeが書いたコードをレビューするとき、次の場所に目を向けます。

パイプはpipefailなしだと左側の失敗を捨てる

#!/usr/bin/env bash
set -e
[ "${PF:-0}" = 1 ] && set -o pipefail
false | cat
echo "reached after pipe (exit of pipeline ignored)"
bash pipe.sh; echo "exit=$?"          # pipefail なし
PF=1 bash pipe.sh; echo "exit=$?"     # pipefail あり
reached after pipe (exit of pipeline ignored)
exit=0
exit=1

pipefail がないと、パイプラインの終了コードは最後のコマンド cat のものになり、set -e は何も検知しません。PF=1 で pipefail を有効にすると、パイプライン全体が失敗となり、set -e が止めます。pipefail だけでは止まらず、set -e と組み合わせて初めて終了コード1になる点に注意してください。

ifの条件内とx=$(…) の挙動

set -e
check() { false; echo "check continued"; }
if check; then echo ok; fi
x=$(false); echo "x assigned, continued"
check continued
ok
exit=1

if check の条件として呼ばれた関数の中では、set -e が無効になります。false の後の行まで実行され、関数は最後の echo の成功で ok に進みました。一方、x=$(false) は代入そのものが失敗として扱われ、その場で止まっています。

export x="$(cmd)" は逆に失敗が隠れます。この型はShellCheckのSC2155として検出できるため、Claude Codeの編集直後にhookで走らせる構成と相性が良い領域です。

set -uはrm -rfの事故を止める

set -euo pipefail
dir="${TARGET_DIR}"
echo "rm -rf $dir/*"
unset.sh: line 3: TARGET_DIR: unbound variable
exit=1

変数が未定義のまま rm -rf "$dir/"* に渡ると、意図しない場所を消しかねません。set -u はここで止めてくれます。空文字を許す場所だけ ${VAR:-} と書く運用になります。

localの同時代入とmacOSの空配列

Claude Codeが関数の中で書きがちな形にも、2つ穴があります。

set -euo pipefail
f() { local out=$(false); echo "local masked the failure: continued"; }
g() { local out; out=$(false); echo "unreachable"; }
f
g
local masked the failure: continued
exit=1

local out=$(false) は、代入と宣言を1行で行うため、local 自体の成功が失敗を覆います。f は最後まで走り、宣言と代入を分けた g だけが止まりました。export で起きるSC2155と同じ構造です。

もう1つは空配列です。

set -euo pipefail
files=()
for f in "${files[@]}"; do echo "$f"; done
echo "loop done"
arr.sh: line 4: files[@]: unbound variable
exit=1

macOS標準のbash 3.2.57では、set -u のもとで空配列を展開すると未定義変数として扱われ、止まります。Homebrewなどで入れた新しいbashでは挙動が違う可能性があり、同じスクリプトが開発機とCIで別の結果になる原因になります。${files[@]+"${files[@]}"} と書くか、配列が空の場合を先に判定する書き方が、標準のbash 3.2でも動く回避策です。

Claude Codeに頼むときは「macOS標準のbash 3.2でも動くように」と対象を明示すると、この種の書き分けを指示に含められます。実行してエラーが出ないことは、前節と同じく出力を貼らせて確かめます。

trapが走らない場面と、ERRトラップの使い方

trap EXIT は強力ですが、すべての終わり方に対応するわけではありません。

終わり方EXIT trap
正常終了、exit、set -e による停止EXIT trap走る
kill -TERM(trapで exit 143 を指定した場合)EXIT trap走る
kill -KILL(SIGKILL)EXIT trap走らない

SIGKILLを受けたプロセスは、終了コード137で即座に消えます。この種の強制終了に備えるなら、一時ディレクトリを次回起動時に掃除する別の仕組みが必要です。

trap 'echo "TERM caught" >&2; exit 143' TERM
trap 'echo "EXIT trap ran" >&2' EXIT

TERMとEXITの両方を設定したスクリプトを kill -TERM で止めると、TERM caught の次に EXIT trap ran が出て、終了コードは143でした。TERMのハンドラが exit を呼ぶことで、EXIT trapが連鎖します。

どこで落ちたかを知りたいときは、ERR trapを併用します。

#!/usr/bin/env bash
set -Eeuo pipefail
trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR
mkdir -p out
cp missing.txt out/
echo unreachable
cp: missing.txt: No such file or directory
failed at line 5: cp missing.txt out/
exit=1

-E を付けると、関数やサブシェルの中の失敗でもERR trapが引き継がれます。Claudeに修正を頼むときも、この1行があれば「何行目のどのコマンドか」をそのまま渡せます。

trap EXITを2回書くと、前のtrapは上書きされる

後始末の対象が2つ以上あるスクリプトで、Claude Codeが追記のつもりで trap を2回書くことがあります。

trap 'echo "cleanup A" >&2' EXIT
trap 'echo "cleanup B" >&2' EXIT
false
cleanup B
exit=1

Aは走りませんでした。同じシグナルへの trap は追記ではなく置き換えです。一時ディレクトリを複数作るなら、後始末の一覧を配列に積み、1つの trap から逆順に回す形が安全です。

cleanups=()
add_cleanup() { cleanups+=("$1"); }
run_cleanups() { local i; for ((i=${#cleanups[@]}-1; i>=0; i--)); do eval "${cleanups[i]}" || true; done; }
trap run_cleanups EXIT
a=$(mktemp -d); add_cleanup "echo remove A; rm -rf '$a'"
b=$(mktemp -d); add_cleanup "echo remove B; rm -rf '$b'"
false
remove B
remove A
exit=1

後から作ったものを先に消す順序になり、|| true で個々の後始末の失敗が残りの後始末を止めることも防いでいます。

hookのスクリプトではset -eが終了コードの意味を変える

Claude Codeのhookとして置くシェルスクリプトでは、終了コードに意味があります。hooksリファレンスでは、終了コードは次の3つに分かれます。

  • 0: 成功。アクションは進む
  • 2: ブロッキングエラー。ブロックできるイベントでは、アクションを止める
  • それ以外: ノンブロッキングエラー。アクションは進む(onFailure を "block" にしたhookを除く)

ここに set -e が絡みます。次の PreToolUse 用スクリプトは、rm -rf を含むコマンドをexit 2で止める意図です。

#!/usr/bin/env bash
set -euo pipefail
cmd=$(jq -r '.tool_input.command')
echo "$cmd" | grep -q 'rm -rf' && { echo "blocked: rm -rf" >&2; exit 2; }
exit 0

標準入力に壊れたJSONを流して試します。

echo 'not json' | bash hook.sh; echo "exit=$?"
jq: parse error: Invalid numeric literal at line 1, column 4
exit=5

jq の失敗で set -e が発動し、終了コードは5になりました。2ではないので、公式の仕様どおりならこのツール呼び出しは止まらずに進みます。守るつもりのhookが、入力が壊れた瞬間に黙って素通りする構造です。

直し方は2通りあります。1つは設定側で onFailure: "block" を付け、hookの失敗をブロック扱いにする方法です。この設定を使うにはClaude Code v2.1.295以降が必要と、公式の表に書かれています。もう1つはスクリプト側で、想定外の失敗をexit 2に寄せる方法です。

#!/usr/bin/env bash
set -euo pipefail
trap 'rc=$?; [ "$rc" -eq 0 ] || [ "$rc" -eq 2 ] || { echo "hook crashed (rc=$rc)" >&2; exit 2; }' EXIT
cmd=$(jq -r '.tool_input.command')
case "$cmd" in *"rm -rf"*) echo "blocked: rm -rf" >&2; exit 2;; esac

同じ壊れた入力で試すと、hook crashed (rc=5) が出て終了コードは2になります。正常な ls は0、rm -rf x は blocked: rm -rf と2です。

この形は、想定外の失敗をすべてブロック扱いにします。作業が進まなくなるリスクもあるので、保護パスの編集禁止のように安全側の判断が要るhookだけに使います。整形や通知のhookには向きません。

claude -pをループで回すスクリプトにも付ける

ベストプラクティスのページには、claude -p を for で回して複数ファイルを移行する例があります。この外側のスクリプトにも、同じ保護が効きます。

#!/usr/bin/env bash
set -euo pipefail
: > failed.txt
while IFS= read -r file <&3; do
  if ! claude --bare -p "Migrate $file to the new logger API and commit. Reply OK or FAIL." \
      --allowedTools "Read,Edit,Bash(git add *),Bash(git commit *)" \
      --permission-mode dontAsk \
      --max-turns 20; then
    echo "$file" >> failed.txt
  fi
done 3< files.txt

--bare はhooks・skills・プラグイン・MCPサーバー・CLAUDE.md を、リポジトリのものもマシンのものも読み込まないモードです。そのため、この記事で勧めた CLAUDE.md の規約もhookによる強制も、--bare のループ内の claude には効きません。保護は外側のスクリプトで担う必要があります。またclaude.aiのログインは使わず、認証は ANTHROPIC_API_KEY などで行います。

ベストプラクティスのページにある例は for file in $(cat files.txt) です。ここでは空白を含むパスに強い while read に置き換えました。ただし while read には落とし穴があります。done < files.txt と書くと、ループ内の claude -p も同じ標準入力を受け継ぎ、files.txt の残りの行を読み込んでしまうため、2件目以降が処理されないことがあります。そこで read -r file <&3 とファイル記述子3番からループ用の行を読み、claude の標準入力と分けています。

ターン上限に達した実行はエラーで終わるため、if ! で受けて失敗したファイルを failed.txt に集めています。一方、依頼文は Reply OK or FAIL ですが、claude -p が FAIL と返しても終了コードは0のままです。failed.txt に入るのはエラー終了したファイルだけで、FAIL と返答したファイルは拾われません。返答の中身で判定したいなら、出力を変数に受けて grep -q '^OK' で調べる形にします。

set -e 単体だと最初の失敗でループ全体が止まります。全件を回して失敗だけ後で見るのか、最初で止めるのかを、依頼のときに決めておく項目です。

まとめ

保護が働くかどうかは、成功する実行からは分かりません。依頼文に「失敗を注入して、後始末の出力と終了コードを貼る」と1行足すだけで、Claude Codeは自分で検証を回します。hookに置くスクリプトだけは、set -e による終了コードの変化が仕様と直結するため、壊れた入力での挙動まで試しておく価値があります。

関連して、zshで起きるBash構文の失敗はClaude Codeがzshでbash構文を書いて失敗する原因に、検証コマンドを軸に据えた別言語の例はCMake presetsとctestを検証コマンドにする手順にあります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. code.claude.com/docs/en/hooks
  3. code.claude.com/docs/en/hooks-guide
この記事を共有:XはてブLinkedIn