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?

Crushのserverモード、認証なしで/v1 APIが素通りした

0
Posted at

はじめに

ターミナルで動くコーディングエージェントに server というサブコマンドがあったら、何を思うでしょうか。筆者は「TUI の裏側を外から叩けるのか」くらいの軽い気持ちで crush server --help を叩きました。ところが README を読み返しても、その説明がほとんど書かれていません。

対象読者は、Crush や同種の CLI コーディングエージェントをローカルや共有サーバーで動かしている開発者です。

Crush は Charm 製のターミナル向け AI コーディングアシスタントで、Go で書かれています。今回検証したのは npm 配布版の v0.88.0 です。この記事では、ドキュメントに載っていない crush server の実体を実際に起動して調べ、どこまで外から操作できてしまうのかを確かめた結果をまとめます。

TL;DR

  • crush server は Go の net/http/v1 配下に REST API を公開します。ルーティング定義は 70 行、OpenAPI 仕様上のパス数は 59 本でした
  • README に server の説明はなく、crush serve という別表記が 1 文だけ登場します
  • 認証機構がありません。ハンドラチェーンは recoverHandler(loggingHandler(mux)) だけで、OpenAPI の securityDefinitionssecurity も未定義でした
  • リクエストに必要な client_id は UUID 形式の検査しか通りません。手元で uuid4() を生成すれば誰でも有効な値を作れます
  • 認証情報なしでワークスペース作成・セッション作成・エージェントへのプロンプト投入まで通り、実際にモデルが走って 148 プロンプトトークン・8 コンプリーショントークン・$0.000196 が計上されました
  • ただし既定のバインド先は Unix ドメインソケットです。TCP 公開は --host tcp://... を明示したときだけ起こります

検証環境と手順

Linux x86_64 のクラウドコンテナ上で、npm 経由でインストールしました。

npm i -g @charmland/crush
crush --version
# crush version v0.88.0

crush --help のコマンド一覧に server があります。

COMMANDS

  ...
  models                       List all available models from known providers
  run [prompt...] [--flags]    Run a single non-interactive prompt
  server [--flags]             Start the Crush server
  session [command]            Manage sessions
  stats [--flags]              Show usage statistics

crush server --help を見ると、--host の既定値が unix:///tmp/crush-0.sock であることが分かります。

FLAGS

  -c --cwd       Current working directory
  -D --data-dir  Custom crush data directory
  -d --debug     Debug
  -h --help      Help for server
  -H --host      Server host (TCP or Unix socket) (unix:///tmp/crush-0.sock)

ホスト指定にはスキームが要る

最初に --host 127.0.0.1:18080 と書いて失敗しました。

  ERROR

  Invalid server host: invalid host format: 127.0.0.1:18080.

tcp:// を付けると起動します。ここは地味にハマりどころで、エラーメッセージが「どう書けば正しいのか」を教えてくれません。

crush server --host tcp://127.0.0.1:18082

ドキュメントを探しても見つからない

README を読みに行くと、server に関する記述は実質ゼロでした。唯一の言及はワークスペース共有の説明中にある次の 1 文だけです。

When Crush is run against a shared backend (for example two TUIs talking to the same crush serve)

コマンド名は server なのに crush serve と書かれており、エンドポイント・プロトコル・認証のいずれについても説明がありません。

一方で、サーバー自体は Swagger UI を同梱していました。

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:18082/v1/docs/
# 301(/v1/docs/index.html へリダイレクト)

OpenAPI 仕様は /v1/docs/doc.json から取れます。

curl -s http://127.0.0.1:18082/v1/docs/doc.json -o sw.json
python3 -c "
import json; d = json.load(open('sw.json'))
print('title:', d['info']['title'], 'ver:', d['info']['version'])
print('paths:', len(d['paths']))
print('securityDefinitions:', d.get('securityDefinitions'), 'security:', d.get('security'))
"

出力は次のとおりです。

title: Crush API ver: 1.0
paths: 59
securityDefinitions: None security: None

つまり「ドキュメントが無い」のではなく、サーバーを起動した人しか読めない場所にある というのが正確でした。そしてその仕様書は、認証スキームを 1 つも定義していません。

エンドポイントの全体像

ソース(internal/server/server.go)のルーティング定義を数えると 70 行ありました。

grep -c 'mux.HandleFunc(' server.go
# 70

代表的なものを抜き出します。

mux.HandleFunc("GET /v1/health", c.handleGetHealth)
mux.HandleFunc("GET /v1/config", c.handleGetConfig)
mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces)
mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents)
mux.HandleFunc("POST /v1/workspaces/{id}/sessions", c.handlePostWorkspaceSessions)
mux.HandleFunc("POST /v1/workspaces/{id}/agent", c.handlePostWorkspaceAgent)
mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/shell", c.handlePostWorkspaceAgentSessionShell)
mux.HandleFunc("POST /v1/workspaces/{id}/permissions/skip", c.handlePostWorkspacePermissionsSkip)
mux.HandleFunc("POST /v1/workspaces/{id}/permissions/grant", c.handlePostWorkspacePermissionsGrant)
mux.HandleFunc("POST /v1/workspaces/{id}/config/provider-key", c.handlePostWorkspaceConfigProviderKey)
mux.Handle("/v1/docs/", httpswagger.WrapHandler)

セッション管理・LSP 制御・MCP サーバー操作・スキル読み込み・権限付与まで、TUI でできることはひととおり API から叩ける設計です。permissions/skippermissions/grant が API に出ているのは、TUI の許可ダイアログを外部クライアントが肩代わりするためでしょう。

ハンドラの組み立ては次の 1 行です。

s.h = &http.Server{
    Protocols: &p,
    Handler:   s.recoverHandler(s.loggingHandler(mux)),
}

パニック回復とアクセスログだけで、認証・認可のミドルウェアは挟まっていません。

認証なしでどこまで通るか

読み取り系は素通りする

ヘッダーもトークンも付けずに叩きます。

curl -s -i http://127.0.0.1:18082/v1/health | head -3
HTTP/1.1 200 OK
Date: Thu, 06 Aug 2026 03:14:02 GMT
Content-Length: 0

バージョン情報も同様です。

curl -s http://127.0.0.1:18082/v1/version
# {"version":"v0.88.0","commit":"unknown","build_id":"dkd26qdf3xmo","go_version":"go1.26.5","platform":"linux/amd64"}

client_id は認証情報ではない

書き込み系はどうか。ワークスペース作成を試すと、client_id を求められます。

curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"path":"/tmp/crushtest"}' http://127.0.0.1:18082/v1/workspaces
# {"message":"invalid client_id"}

ここで「やはり何らかの資格情報が要るのか」と思いましたが、適当な文字列を入れたときのエラーが答えを教えてくれました。

curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"path":"/tmp/crushtest","client_id":"probe-1"}' http://127.0.0.1:18082/v1/workspaces
# {"message":"invalid client_id: invalid UUID length: 7"}

弾かれた理由は「知らない ID だから」ではなく「UUID の長さではないから」でした。つまり検査しているのは 形式だけ です。手元で UUID を作れば通ります。

CID=$(python3 -c "import uuid; print(uuid.uuid4())")
curl -s -X POST -H 'Content-Type: application/json' \
  -d "{\"path\":\"/tmp/crushtest\",\"client_id\":\"$CID\"}" \
  http://127.0.0.1:18082/v1/workspaces
{"id":"3dce928f-8f18-494e-8db1-9c074fd8851b","path":"/tmp/crushtest","data_dir":"/tmp/crushtest/.crush","version":"v0.88.0","config":{"models":{"large":{"model":"gpt-5.6-sol","provider":"openai",...

client_id はサーバーが払い出す資格情報ではなく、クライアントが自称する識別子でした。マルチクライアント接続を区別するための ID であって、アクセス制御には使われていません。

エージェントを外から走らせる

ワークスペースが取れたので、セッションを作ってプロンプトを投げます。

WID=3dce928f-8f18-494e-8db1-9c074fd8851b
SID=$(curl -s -X POST -H 'Content-Type: application/json' -d '{"title":"probe2"}' \
  "http://127.0.0.1:18082/v1/workspaces/$WID/sessions" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")

curl -s -w "[HTTP %{http_code}]\n" -X POST -H 'Content-Type: application/json' \
  -d "{\"session_id\":\"$SID\",\"prompt\":\"Reply with exactly: CRUSH_API_OK\"}" \
  "http://127.0.0.1:18082/v1/workspaces/$WID/agent"
# [HTTP 202]

202 は受理されただけなので、本当にモデルが走ったのかを SSE で確認します。

curl -s -N "http://127.0.0.1:18082/v1/workspaces/$WID/events?client_id=$CID" > events.log

events.log の末尾に、セッション更新イベントが流れていました。

data: {"type":"session","payload":{"type":"updated","payload":{"id":"17ce59e1-...","title":"CRUSH_API_OK","message_count":2,"prompt_tokens":148,"completion_tokens":8,"summary_message_id":"","cost":0.000196,"created_at":1785986146,"updated_at":1785986148,"is_busy":false,"attached_clients":0}}}

セッションタイトルが CRUSH_API_OK に自動更新され、prompt_tokens: 148 / completion_tokens: 8 / cost: 0.000196 が計上されています。認証情報を一切持たないクライアントが、サーバー運用者の API キーでモデルを走らせ、課金を発生させたということです。

一連の流れを図にすると次のようになります。

誤解しないための注記

公平を期すために、実測で確認できた「そこまで悪くない」点も挙げます。

API キーの生値は返っていません。 ワークスペースの config レスポンス(19,225 バイト)に環境変数の実値が含まれていないかを機械的に確認しました。

python3 -c "
import os, json
d = open('wcfg.json').read()
for k in ('OPENAI_API_KEY', 'GEMINI_API_KEY'):
    print(k, 'value_present_in_response:', bool(os.environ.get(k) and os.environ[k] in d))
"
# OPENAI_API_KEY value_present_in_response: False
# GEMINI_API_KEY value_present_in_response: False

api_key というフィールド名は仕様上存在しますが、参照名で保持されており生のキーは露出しませんでした。

既定はネットワークに開きません。 --host の既定値は unix:///tmp/crush-0.sock です。何も指定せずに crush server を起動する限り、TCP ポートは開きません。今回のように外部から叩ける状態になるのは、--host tcp://... を自分で指定したときだけです。

ワークスペースはクライアントに紐づいて消えます。 SSE の /events を張らずにワークスペースを作り、しばらくして同じ ID を叩いたところ、こう返りました。

{"message":"workspace not found"}

接続中のクライアントがいなくなるとワークスペースが破棄される作りです。放置された状態が延々と残り続けるわけではありません。

著者視点の一次所見

一番効いた発見は、エラーメッセージの文言が設計意図を漏らしていた ことです。invalid client_id: invalid UUID length: 7 という 1 行がなければ、筆者は「client_id はサーバーが発行するトークンで、取得手段が分からないから触れない」と結論して調査を終えていたと思います。長さの検査に失敗したという報告は、逆に「長さ以外は見ていない」ことを教えてくれました。

未文書の API を調べるとき、正常系のレスポンスより バリデーションエラーの粒度 を見るほうが速い、というのは今回持ち帰った実感です。path is requiredinvalid client_id の 2 種類を出し分けている時点で、必須フィールドの構成もほぼ推測できました。

もう 1 つは、README とバイナリで情報量が逆転していたことです。/v1/docs/doc.json に 59 パスの OpenAPI 仕様が入っているのに、README には crush serve という 1 語しかありません。実装が先行してドキュメントが追いついていない機能は、「未文書だから使われていない」ではなく「使えるのに危険度が知られていない」 状態になりがちです。今回の認証の欠如も、Swagger を開いた人だけが気づける形になっていました。

運用でどうするか

実測を踏まえて、筆者が取る対策は次の 3 つです。

  1. 既定の Unix ソケットのまま使う。 crush server を素で起動する限りネットワークには出ません。同一ホスト内の TUI 連携が目的なら、そもそも TCP にする理由がありません。
  2. TCP バインドが必要なら 127.0.0.1 に限定する。 --host tcp://0.0.0.0:PORT は、そのホストに到達できる全員にエージェント操作権を渡すのと同じです。今回の検証で使ったのも tcp://127.0.0.1:18082 です。
  3. リモートから使うならリバースプロキシで認証を足す。 サーバー側に認証の口が無い以上、mTLS や Basic 認証を前段で被せるか、SSH ポートフォワード越しに限定するのが現実的です。permissions/grantagent/sessions/{sid}/shell が無防備に並んでいることを考えると、ここは省略できません。

まとめ

Crush v0.88.0 の crush server は、/v1 配下に 59 パスの REST API を持つ本格的なバックエンドでした。そして認証機構は実装されておらず、client_id は UUID の形式検査しか通しません。実際に、資格情報を持たないクライアントからワークスペース作成・セッション作成・プロンプト投入まで到達し、運用者の API キーで $0.000196 の課金が発生することを確認しました。

救いは既定が Unix ドメインソケットであることです。裏を返せば、--host tcp://... を書いた瞬間に前提が変わります。この記事の内容は 2026 年 8 月 6 日時点の v0.88.0 での実測であり、今後のバージョンで認証が追加される可能性はあります。TCP で公開する運用をしている方は、まず自分の環境で curl http://ホスト:ポート/v1/version が通るかどうかを確かめてみてください。

関連記事

参考リンク

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?