コンテンツへスキップ

bash 構文の SSH コマンドが csh で詰まる — 「/bin/sh -c」ラップで横断対応した話

ある日、ユーザーから不思議な不具合報告が届きました。新規サイト追加モーダルで SSH プロファイルを選んでも、WordPress インストールパスの「自動検出」が「パスが見つかりませんでした」で必ず失敗する。一方で WP-CLI パスのテストボタンは正常に動く。同じ SSH 接続を使っているのに、片方だけ失敗する非対称な症状でした。

原因を辿ると、サーバー側で起きていたのは csh と bash の互換性問題。今回はその修正の経緯と、同型バグを横断 grep で一掃した話、そして再発防止のために置いた静的解析テストの設計をまとめておきます。

症状の核 — find: 2: unknown primary or operator

エラー出力を読みに行くと、サーバー側でこんなメッセージが出ていました。

find: 2: unknown primary or operator

find コマンド自体は POSIX で標準的なはずなのに、2 という謎の引数で死んでいる。これは find ... 2>/dev/null2>/dev/nullfind の引数として渡されてしまっている、つまり シェルがリダイレクト構文を解釈していない症状です。

補足: 2>/dev/null は標準エラー出力をどこにも出さない(捨てる)ためのリダイレクト構文。Bourne shell (sh) / bash の標準機能ですが、csh (C shell) では構文が違うため解釈されません。

さくらインターネットのログインシェルは csh

既に公開した WP-CLI が動かない 4 ホスト調査 で、レンタルサーバーごとの WP-CLI 配置の差を整理しました。その中で さくらインターネットはユーザーのデフォルトログインシェルが csh / tcsh という事実があります。

ここで起きるのが、paramiko (Python の SSH ライブラリ) の exec_command がユーザーのログインシェルを通してコマンドを実行するという仕様です。さくらに対して find ... 2>/dev/null を送ると、csh がそれを解釈しようとして詰まる。実機エラーの正体はこれでした。

bash 前提で書かれていた構文の代表例:

  • 2>/dev/null(リダイレクト)
  • [ -f path ](テスト構文)
  • for X in ...; do ... done(ループ)
  • cmd1 && cmd2(短絡評価)
  • \( ... \)(サブシェル)

これらが csh で「unknown primary or operator」「Missing }」等のエラーで壊れます。

「1 箇所直したから全部 OK」が崩れる

実はこの問題は今回が初遭遇ではありませんでした。少し前のラウンドで、SSH プロファイルの接続テスト機能 test_ssh_profile が csh 環境で壊れているのを検出し、コマンドを /bin/sh -c '...' でラップする修正を入れていました。

# 既に修正済みだった test_ssh_profile
result = c.run('/bin/sh -c ' + shlex.quote('echo ok'),
               hide=True, warn=True)

/bin/sh -c "..." でコマンドをラップすると、ログインシェルが csh であっても /bin/sh(POSIX sh)が解釈することが保証されます。シンプルですが効くトリックです。

問題は、この修正が test_ssh_profile という 1 つの API にしか入っていなかったこと。「同じパターンが他の箇所にないか?」を当時 grep しなかったため、SSH 経由でコマンドを送っている別の API には同じ脆弱性が残ったままになっていました。

具体的に残っていた箇所:

箇所 役割
/api/discover_server_paths WP インストールパス自動検出
WP-CLI 自動検出(プロファイル保存時) サーバーごとの wp コマンドパス検出
test_wpcli エンドポイント WP-CLI パスの単体テスト
プラグイン一覧取得(複数経路) サイト一覧モーダル等で使う wp plugin list

要するに、csh ホストではこれらが軒並み壊れていた。WP-CLI テストボタンだけが先に修正されていたから、ユーザーから見ると「テストは通るのに自動検出だけ失敗」の不思議な非対称になっていた、というのが真相でした。

修正 — _safe_run ヘルパーに集約

各箇所で個別に /bin/sh -c ラップを書くと書き漏らしが再発します。そこで、SSH 経由でコマンドを送る既存箇所をすべて 1 つのヘルパー関数経由に統一しました。

def _safe_run(c, cmd, **kwargs):
    """csh 等のログインシェルに依存しないよう /bin/sh -c で明示ラップ。
    POSIX sh が確実に解釈するためのデフォルト経路として全 SSH 系 API で使う。
    """
    wrapped = '/bin/sh -c ' + shlex.quote(cmd)
    return c.run(wrapped, **kwargs)

これで「c.run(cmd) を直接呼ぶ箇所」がコードベースからほぼ消え、新しい SSH コマンドを追加するときも自然と _safe_run を経由する流れになります。

再発防止 — 「生 c.run() の混入」を機械で塞ぐ

ここで一段だけ追加の防御を入れました。「うっかり生の c.run() で sh 構文を書く」事故を二度起こさないためです。

# tests/test_csh_safe_run_wrapping.py の概念図
def test_no_raw_c_run_with_sh_syntax():
    """site_manager_web.py 内に sh 構文を含む生 c.run() が
    残っていないことを静的解析で保証する。"""
    for call in find_c_run_calls('site_manager_web.py'):
        arg = call.argument_text
        if contains_sh_syntax(arg):
            # /bin/sh -c でラップされていない sh 構文の c.run() は許さない
            assert arg.lstrip("'").lstrip('"').startswith('/bin/sh -c'), \
                f"Raw c.run() with sh syntax detected at line {call.lineno}"

判定範囲を c.run( の引数表現の中だけに絞ることで、コメントや docstring に書かれた 2>/dev/null を false positive で拾わない設計です。

このテストを CI に含めることで、将来うっかり生の c.run('find ... 2>/dev/null') を書いたら、その PR は CI で落ちるようになります。横展開を忘れた時のフォールバックが機械化されている、という安心感は予想以上に効きます。

学び — 「同型バグのコピーし忘れ」を二段構えで塞ぐ

今回のラウンドから取り出せた一般化できそうな原則は次の 2 つでした。

  1. 最初の発見時に「同じパターンが他の箇所にないか」を grep する — 修正パターンが「コピペで複数箇所に効く」タイプは、人間が横展開を覚えていられない。最初の修正時に横断 grep をかけてしまうのが第一防御線
  2. 横展開を忘れても落ちる回帰テスト — 静的解析で「禁じ手の混入」を検出するテストを置く。1 が漏れても CI が捕まえる第二防御線

「test_ssh_profile を直したから csh は全部大丈夫」と思い込んだ判断が、5 箇所の残存バグを温存していた、という反省も込みです。同じシェル非互換問題と戦っている方の参考になれば嬉しいです。