シェルスクリプトに set -e を指定したり、Python で簡単な集計スクリプトを書いたりする際、「外部コマンドが失敗したらスクリプトは止まるはずだ」「とりあえず空リストを返しておけば後続のループ処理が安全に動くだろう」と思い込みがちです。

しかし、外部コマンドの取得失敗が空リストや空文字に化けてしまうと、「対象データが 0 件である(正常・未処理なし・安全)」という状態と、「取得そのものに失敗して存在を確認できていない(異常)」という状態が、まったく同じ表示になってしまいます。

外部コマンド集計における「偽の 0 件」とは、コマンドの異常終了や出力のパース失敗が空配列や空文字列へフォールバックされることで、実際には「取得に失敗した」状態であるにもかかわらず「対象データが 0 件(正常・安全・未処理なし)」と誤認される現象です。

筆者が個人で開発・運用しているツール集(claude-skills)において、2026年10月下旬の実働 6 日間(2 暦週にまたがる期間)に、まったく同じ型の修正が 4 か所の別々のスクリプトに入りました。あるスクリプトでは Bash のプロセス置換が終了コードを捨てていたために「未処理タスク 0 件」と誤読され、別のスクリプトでは Python の例外処理やフォールバック構文がエラーを握りつぶし、安全側に誤認されていました。

本記事では、Bash のプロセス置換 < <(cmd) や Python の例外処理で実際に発生した 4 つのコード差分と実測検証をもとに、なぜ人間も AI もこの罠に落ちるのか、そして「未確認」と「0 件」を分ける 3 値化設計と即座の停止(Fail Fast)の規律を解説します。

なぜ「空へのフォールバック」を選んでしまうのか:実務での 4 つの失敗パターン

スクリプトを書く際、途中でエラーが発生したときに処理をクラッシュさせず、空リスト [] や空文字列 "" を返して後続の処理をそのまま流したくなる誘惑があります。エラーで処理を止めるよりも、デフォルト値を与えて処理を継続させた方が「落ちにくい堅牢なコード」に見えるからです。

しかし、これはソフトウェア工学で「failing slowly(ゆっくりと壊れる)」と呼ばれるアンチパターンです1。問題をその場で表面化させず自動的に回避しようとすると、不正な状態のまま処理が継続し、後から原因究明が極めて困難な奇妙な故障を引き起こします。とりわけ集計や検査のスクリプトでこれが起きると、異常が正常な「0 件」として偽装され、最も危険な「安全側の誤認」を生み出します。

筆者の環境で発生した 4 つの実務事例を順に見ていきます。

1. プロセス置換が終了コードを握りつぶす:Bash での open 0 件化

タスクや意思決定記録(adrs)の open 一覧を取得して優先度順に並べるシェルスクリプト list_open.sh で発生した事例です。

修正前のスクリプトは、外部コマンドから 1 行ずつ読み出すために、Bash のプロセス置換 < <(...) を使ってループを回していました。

# 修正前の構造イメージ
while IFS= read -r line; do
  ...
done < <(adrs --ng -C "$INBOX" list --status open)

この書き方には致命的な盲点があります。adrs コマンドが何らかのエラー(例: 設定不備や引数ミスによる exit 3)で失敗して標準出力が空になった場合でも、プロセス置換 < <(...) 内の終了コードは、呼び出し元のパイプラインや while ループの終了ステータスには反映されません2。while ループの成否はループ条件である read コマンドが EOF に達した結果(正常終了、rc=0)によって決まります。さらに、単語展開の過程で実行されたサブシェル環境でエラーが発生しても、set -e によってシェルが終了しないことが POSIX 仕様でも定められています3。

その結果、スクリプトの先頭で set -e を宣言していても何のエラーも出さずに rc=0 で完了し、出力結果が空になります。外部の呼び出し元からは「open なレコードが 0 件だった」のか「取得に失敗した」のか区別がつきませんでした。

これを最小限のコードで再現したのが以下の検証です。

引用元: 検証スクリプト(hands-on-log.md §5)

bash -c 'set -e; while IFS= read -r line; do echo "line: $line"; done < <(exit 1); echo "finished with rc=$?"'
bash -c 'set -e; list=$(exit 1); echo "finished"' || echo "stopped with rc=$?"

実行結果は以下のようになります。

finished with rc=0
stopped with rc=1

1 行目のプロセス置換では、内部で exit 1 が起きているにもかかわらず finished with rc=0 となり、ループを素通りして正常終了しています。一方、2 行目のように一度変数代入 list=$(...) を経由させると、コマンド置換の終了ステータスが代入文全体のステータスとなるため4、set -e が即座に検知して stopped with rc=1 で停止します。

この挙動を踏まえ、list_open.sh ではプロセス置換をやめ、一度変数に出力を受けてからヒアストリング(Here String)<<<"$list" でループに流し込む形に変更しました5。

引用元: 個人で開発しているツール集(claude-skills)の list_open.sh

# プロセス置換 < <(adrs …) だと adrs の失敗が捨てられ、rc=0・出力空で open 0 件と区別できない。先に変数へ取って set -e を効かせる
list=$(adrs --ng -C "$INBOX" list --status open)
while IFS= read -r line; do
  [ -n "$line" ] || continue
  ...
done <<<"$list"

これにより、adrs コマンドが非 0 で失敗した場合は list=$(...) の代入時点で set -e により即座にスクリプトが停止し、「偽の 0 件」を出力する余地を排除できました。

2. 例外の握りつぶしが招く「検査すり抜け」:Python での 0 件化

サブエージェントのログファイル(JSON Lines 形式)を走査し、「外向きの書き込みツール呼び出しが 0 件であること」を検査する Python スクリプト subagent_tools.py で発生した事例です。

修正前のコードでは、各行のパース時に発生する例外を安易にスキップしていました。

引用元: 個人で開発しているツール集(claude-skills)の subagent_tools.py

# 修正前(83d8947^)
try:
    content = (json.loads(line).get("message") or {}).get("content")
except (ValueError, AttributeError):
    continue

# 修正後(83d8947)
try:
    content = (json.loads(line).get("message") or {}).get("content")
except (ValueError, AttributeError):
    unreadable.append(n)
    continue
...
if unreadable:
    stop(f"{found[0]} の {len(unreadable)} 行(行 {','.join(map(str, unreadable))})が JSON として読めず数えられていない。"
         f"読めた行の集計は {summary} だが、外向きの書き込みが 0 件の確認には使えない")

途中でプロセスのクラッシュやネットワーク切断によってログが途切れると、不完全な JSON 行が発生することがあります。修正前のコードは、そうした読めない行を continue で読み飛ばして残りの行だけで集計を継続していました。

もしスキップされた行に外向きの書き込みツール呼び出しが含まれていた場合、集計結果は (none)(0 件)となり、「外向きの書き込みは行われなかった」と誤認されます。これはセキュリティ検査や品質ゲートにおいて、エラーを検知しながら適切な対処を行わず握りつぶしてしまう重大な弱点です(CWE-390)6。Python のスタイルガイド(PEP 8)でも、過剰な例外捕捉は潜在的なバグを隠蔽するため避けるべきだと強く戒められています7。

修正後は、JSON として読めない行が 1 行でもあれば unreadable リストに記録し、集計結果を破棄して未読行番号を添えた上で STOP: を出力して exit 1 で即座に終了させるように改修しました。実環境のログ 466 本を用いた検証でも、正常なログを誤って停止させることなく、破損行を確実に検知できることが確認されています。

3. 空配列へのフォールバックと「未確認」への 3 値化:タスク差分と競合検出

タスク管理ツール(Taskwarrior)の export 出力を集計する 2 つのスクリプト(actor_diff.py と float.py)でも、同種のフォールバックに起因する不具合が発生しました。

1 つ目の actor_diff.py は、分類ルールの変更前後にタスクを export して差分を検証するスクリプトです。修正前は、外部コマンドの実行結果を保持する関数 tasks() において、コマンド成功時のみ結果を格納し、失敗時は単に空リスト [] を返していました。

# 修正前: コマンドが失敗すると空リストが返る
def tasks():
    out = []
    for flt in (["status:pending"], ["status:completed", "end.after:now-30d"]):
        r = subprocess.run(["task", "rc.verbose=nothing", "rc.json.array=on", *flt, "export"], capture_output=True, text=True)
        if r.returncode == 0:
            out += json.loads(r.stdout, strict=False)
    return out

Python の subprocess.run は、デフォルトではコマンドが非 0 で失敗しても例外を送出しません8。そのため、終了コードの検査から漏れた分岐がそのまま [] を返してしまうと、戻り値の未検査(CWE-252)9 により、後続の差分比較ロジックが「差分 0 件(変更なし)」と誤った結論を出してしまいます。

そこで修正後は、1 本でも export に失敗した場合は取得できたデータも含めて一覧を破棄し、失敗理由を返してメイン処理で明示的に「未確認」と出力して終了する設計に変更しました。

引用元: 個人で開発しているツール集(claude-skills)の actor_diff.py

# 修正後(db0ff34)
def tasks():
    """(task 一覧, 失敗の説明)。1 本でも export に失敗したら一覧は捨てる: 取れた分だけで「0 件」と出すと、検査していないのに差分なしと読まれる"""
    out = []
    for flt in (["status:pending"], ["status:completed", "end.after:now-30d"]):
        r = subprocess.run(["task", "rc.verbose=nothing", "rc.json.array=on", *flt, "export"], capture_output=True, text=True)
        if r.returncode != 0:
            return [], f"{' '.join(flt)} の export が exit {r.returncode}: {r.stderr.strip()[:120]}"
        try:
            out += json.loads(r.stdout, strict=False)
        except ValueError as e:
            return [], f"{' '.join(flt)} の export が JSON でない: {e}"
    return out, ""

def main():
    ...
    data, why = tasks()
    if why:
        print(f"actor_diff: TW の export に失敗したので未確認({why})")
        return

もう 1 つの float.py(running_peers 関数)は、並行して動いている他のタスクを取得し、作業対象ファイルが重複していないか(overlap=)を判定するスクリプトです。

ここでは Python でよく見られる r.stdout or "[]" というイディオムが使われていました。

引用元: 個人で開発しているツール集(claude-skills)の float.py

# 修正後(853b63e)
def running_peers(repo, uuid, u8s):
    r = run(["task", "rc.verbose=nothing", "rc.json.array=on", "status:pending", "export"])
    # 非 0 で stdout が空だと `or "[]"` が 0 件に化け、overlap 行が出ず「重ならない」と読まれる(completed_recent と同じ)
    if r.returncode != 0:
        raise RuntimeError(f"pending の取得に失敗(task が rc={r.returncode}): " + " ".join(r.stderr.split())[:160])
    try:
        tasks = json.loads(r.stdout or "[]", strict=False)

task export が異常終了して標準出力が空文字列 "" だった場合、r.stdout or "[]" は評価結果として "[]" を返します。その結果、何のエラーも出ずに空リストとしてパースされ、走っている並行タスクが「0 件」と見なされてしまいます。本来であれば警告すべき重複チェックが行われず、「他のタスクと重なっていない(安全)」と誤認される状態でした。

修正後は、r.returncode != 0 の場合に即座に RuntimeError を送出させ、呼び出し元でそれをキャッチして overlap=未確認 を出力するように変更しました。

すべてを静的型付け言語で書けば解決するのか:軽量言語との付き合い方

型による強制とワンライナーのトレードオフ

これらの問題に直面したとき、「そもそもすべてのスクリプトを Rust のような静的型付け言語で書けば解決するのではないか?」という疑問が湧くかもしれません。

Rust では回復可能なエラーを Result<T, E> 型で表現し、コンパイル時にエラーハンドリングを明示的に行うことが強制されます10。未処理のエラーを放置したままデータを取り出すことは構文レベルで防がれるため、こうしたサイレントな見落としを激減させることができます。確実性や変なエラーの少なさを最優先するなら、静的な型を持つ言語を選ぶのが合理的であり、筆者自身も何か本格的なツールを作る際は Rust を選ぶことが多くあります。

しかし、日常の開発業務や AI との対話の中でちょっとした集計スクリプトやワンライナーを書かせる用途に、Rust は向いていません。コンパイルの手間や依存関係の定義、厳密な型合わせのオーバーヘッドが大きすぎるためです。結局のところ、現場ではシェルスクリプトや Python といった軽量言語を使い続けることになります。

問題は、型による強制が効かない軽量言語を使う場面ほど、雑な書き方や安易なエラーの握りつぶしが発生しやすいという点です。「動けばいい」と書いた小さなスクリプトが、いつの間にか自動化パイプラインの中核に組み込まれ、サイレントフェイルの温床になっていきます。言語の型システムに頼れないからこそ、スクリプトを書く際の明確な規律と設計思想が求められます。

人間も AI も同じ罠に落ちる:エラー考慮漏れの共通構造

今回修正した 4 か所のコードは人間が書いたものですが、AI エージェントにコードを生成させた場合にも、まったく同じパターンで空フォールバックや例外の握りつぶしが頻出します。

AI が生成するコードに変なエラー処理が混ざるのは、人間が日常的に書いているコードの中に同種の間違い(エラー考慮漏れや安易なデフォルト値への逃げ)が無数に存在しているからではないかと筆者は考えています。人間が値の 0、空配列、null の違いを明確に意識せずに雑なスクリプトを書くのと同じように、AI もまたそれらを安易に同一視してコードを組み立ててしまいます。

人間が間違えやすい箇所は、AI も同じように間違えます。人間が間違える以上、AI が間違えるのも当然のことであり、それを過度に一般化して嘆く必要はありません。重要なのは「AI がバグを出した」とツールを非難することではなく、人間自身が長年無意識に行ってきた「空値への安易なフォールバック」という悪習を自覚し、エラーを握りつぶさない構造を仕組みとして構築することです。

エージェント連携で跳ね上がる「偽の 0 件」のリスク

「0 件」と「未確認」を分ける 3 値化設計

人間がターミナルで対話的にスクリプトを実行している段階であれば、「出力が空だが、処理時間が一瞬すぎた」「何となく違和感がある」と気づいて手動で再実行できる余地があります。

しかし、自律型 AI エージェントや自動化パイプラインにスクリプトの出力を渡す場合、リスクの次元が根本から変わります。AI エージェントにとって「0 件」という出力は、「競合タスクなし」「書き込み違反なし」「変更差分なし」という、次のアクションへ進むための強力な 安全シグナル として解釈されます。

取得失敗のエラーが空リストに化けてしまうと、エージェントは「安全が確認された」と確信し、破壊的な処理や誤ったタスク完了判定へと突き進んでしまいます。したがって、自動化やエージェント連携を前提とする出力インターフェースは、「有」と「無」の 2 値(ブール値)ではなく、「有」「無」「未確認(エラー)」の 3 値 として明示的に設計しなければなりません。

失敗時の即座の停止(Fail Fast)と未確認表示の使い分け

ただし、すべての異常で単にスクリプトを異常終了(クラッシュ)させれば良いわけではありません。用途に応じて「即座の停止」と「未確認の 3 値化表示」を使い分ける必要があります。

  • 即座の停止(Fail Fast): セキュリティ検査や品質ゲート(例: subagent_tools.py の外向きツール呼び出し検査)など、後続の処理を実行してはならない不可逆・クリティカルな場面では、1 行のパースエラーでも即座に exit 1 や STOP: で停止させます。中途半端な集計結果を絶対に外部へ漏らさないことが最優先です。
  • 未確認(Unchecked)の 3 値化表示: ダッシュボードの表示や作業候補の提示(例: actor_diff.py や float.py)のように、全体の状況を可視化しつつ人間の判断や再試行を仰ぎたい場面では、一覧を空にするのではなく「未確認(エラー理由)」という状態を出力して安全側に倒します。

外部コマンドやデータ取得を伴うスクリプトにおいて、異常終了やパースエラーをどのように処理すべきか、代表的な 3 つのアプローチを比較します。

方式異常時の挙動メリット最大のリスク推奨されるユースケース
空配列フォールバック[] や "" を返し正常終了後続処理がクラッシュせず流れる失敗が「0 件(正常・安全)」に偽装される欠落が業務上まったく無害な装飾的表示のみ
即座の停止(Fail Fast)例外送出や exit 1 で中断異常の拡大や誤認を確実に阻止できるバッチや対話パイプライン全体が止まるセキュリティ検査、CI/CD ゲート、破壊的操作の手前
3 値化表示(未確認)未確認(理由) を出力して継続異常を可視化しつつ判断材料を提供できる呼び出し元で 3 値を解釈するロジックが必要タスク一覧、並行ワーカー競合検知、ダッシュボード

自律エージェントや自動化パイプラインにデータを渡すインターフェースでは、空配列フォールバックを原則禁止とし、不可逆なゲートには即座の停止、状況把握には 3 値化表示を選択するのが安全な設計基準となります。

よくある質問(FAQ)

Q. Bash の set -e を設定していればプロセス置換のエラーも検知できますか?

A. 検知できません。Bash のプロセス置換 < <(cmd) は非同期サブシェルとして実行される単語展開の一種であり、その終了ステータスは呼び出し元のパイプラインや set -e に伝播しません。出力を一度変数代入 list=$(cmd) で受けることで、コマンド置換の終了ステータスに対して set -e を効かせることができます。

Q. 外部コマンドの失敗を空リストでフォールバックさせるとなぜ危険なのですか?

A. 「データが 0 件である(正常)」という状態と、「取得に失敗して存在を確認できていない(異常)」という状態が同じ表現になり、障害が隠蔽されるからです。特に自動化スクリプトや AI エージェント連携では、「差分なし」「競合なし」と安全側に誤認され、破壊的操作や誤判定を引き起こす原因になります。

まとめ

本記事で解説した集計スクリプトの設計規律をまとめます。

  • 空配列・空文字フォールバックを疑う: 外部コマンドやパースの失敗を安易に空値で受けると、正常な 0 件と未確認の異常が混同され、サイレントフェイルを引き起こします。
  • Bash のプロセス置換は変数代入で受ける: < <(cmd) は set -e を素通りするため、変数代入 list=$(cmd) とヒアストリング <<<"$list" の組み合わせで安全にループへ渡します。
  • エージェント連携の出力は 3 値化する: 自律エージェントに渡す集計結果は、有・無の 2 値だけでなく「未確認(エラー)」を含めた 3 値としてインターフェースを設計します。
  • 軽量言語ほどエラー規律を徹底する: 型システムによる強制が効かないシェルスクリプトや Python だからこそ、Fail Fast の即座停止と戻り値検査を明示的に組み込む規律が不可欠です。

開発プロセスの標準化、自動化パイプラインの品質ゲート設計、AI Operations 導入に関するご相談は、ぜひ コンタクトフォーム よりお気軽にお問い合わせください。

Footnotes

  1. Fail Fast Jim Shore(編集: Martin Fowler)、IEEE Software, September/October 2004, pp. 21-25。エラーを隠蔽して後から異常が発生する “failing slowly” の問題点は pp. 21-22、即座かつ可視的に失敗させる Fail Fast の原則は p. 21。 ↩

  2. GNU Bash Reference Manual: Process Substitution Free Software Foundation、Bash Reference Manual Edition 5.2, 2022年9月更新。プロセス置換 <(list) が非同期サブシェルとして実行され単語展開として扱われる仕様。 ↩

  3. POSIX.1-2024 Shell Command Language §2.15 Special Built-In Utilities (set) The Open Group / IEEE Std 1003.1-2024, 2024年版。単語展開中のサブシェルエラーが -e で終了しない仕様および while 条件評価中の挙動。 ↩

  4. GNU Bash Reference Manual: The Set Builtin Free Software Foundation、Bash Reference Manual Edition 5.2, 2022年9月更新。-e(errexit)オプションの仕様および変数代入文におけるコマンド置換終了ステータスの伝播。 ↩

  5. GNU Bash Reference Manual: Here Strings Free Software Foundation、Bash Reference Manual Edition 5.2, 2022年9月更新。<<<word による標準入力供給仕様。 ↩

  6. CWE-390: Detection of Error Condition Without Action MITRE Corporation、Common Weakness Enumeration, 2006年策定。エラーを検知しながら適切な対処を行わず握りつぶす脆弱性。 ↩

  7. PEP 8 – Style Guide for Python Code Guido van Rossum, Barry Warsaw, Nick Coghlan、2001年策定(Programming Recommendations)。裸の except: 節や過剰な例外捕捉がバグを隠蔽(masking bugs)するリスクの指摘。 ↩

  8. Python 3 Documentation: subprocess — Subprocess management Python Software Foundation、2026年更新。subprocess.run の戻り値 CompletedProcess と returncode による終了コード検査仕様。 ↩

  9. CWE-252: Unchecked Return Value MITRE Corporation、Common Weakness Enumeration, 2006年策定。関数の戻り値・終了コードを検査しないことによる不正状態の伝播。 ↩

  10. The Rust Programming Language: Chapter 9 – Error Handling Steve Klabnik and Carol Nichols, with contributions from the Rust Community、2023年更新。Result<T, E> によるコンパイル時のエラー処理強制。 ↩