
Claude Codeの通信に自前ゲートウェイを挟む ― WASI 0.3とWACによる実装
Claude Code に自前ゲートウェイを挟む — WASI 0.3 と WAC で作る最小 LLM ゲートウェイ
Introduction
最近は Claude Code を企業で導入するケースが増えてきました。
そうなると、避けて通れないのがセキュリティです。
例えば、「任意のキーワードや機密情報が LLM に送られていないか」という問題があるとします。
こういった問題は、特に金融・医療・公共のような業種で、
「送ってよい情報かどうか」を送信前に技術的に制御でき、
ポリシーを適用した証跡を残せることが重要になったりします。
本記事は Claude Code を対象にします。Claude Desktop(Enterprise)はマネージド設定、Coworkは専用の設定キーを使い、後述の
ANTHROPIC_BASE_URLの手順とは別なので注意してください。
Claude Code には permission・hooks・managed-settings などの組み込みの統制機能があります。
入力(プロンプト)単位の遮断なら UserPromptSubmit hook でも可能で、
送信前に内容を検査して拒否できます。
ただし、会話履歴や tool result まで含んだ「最終的な HTTP リクエスト」と「応答ストリーム」を
一元的にハンドリングする場合、hooksでは難しく、ゲートウェイやProxyが必要です。
(モデル API の全リクエスト/応答を1箇所で見られる・ストリーミングにも介入できる)
Claude Code では ANTHROPIC_BASE_URL という環境変数で
接続先を自前のエンドポイントに向けられるので、↓のように、
Claude Code 本体には手を入れず、通信に任意のガードレールを差し込めます。
Claude Code → 自前ゲートウェイ(検査・遮断・マスク等)→ api.anthropic.com
本記事では、WASM を合成して、自前のゲートウェイ/Proxy をつくってみます。
About Claude Code
Claude Code の接続先を変更する
Claude Code はリクエストの送り先を環境変数で制御できます。
主な環境変数は以下。
ANTHROPIC_BASE_URL:送信先のURL。ここを自前ゲートウェイに向けるANTHROPIC_API_KEY(x-api-keyヘッダ)/ サブスクの OAuth トークンCLAUDE_CODE_OAUTH_TOKEN(Authorization: Bearer)… 認証ANTHROPIC_CUSTOM_HEADERS… 任意のカスタムヘッダを付与(Name: Value形式、複数は改行区切り)
注意:
ANTHROPIC_BASE_URLをapi.anthropic.com以外のホストに向けると、MCP tool search が既定で無効になるので注意してください(ゲートウェイがtool_referenceを転送できるならENABLE_TOOL_SEARCH=trueで再有効化できる)。
ゲートウェイは公式機能なので、クライアントが送る認証ヘッダ
(x-api-key / Authorization: Bearerなど)をそのまま転送すれば、
API キーを使用した状態でもMax/Team/Enterpriseプランでもゲートウェイ越しに動きます。
ゲートウェイは /v1/messages と /v1/messages/count_tokens を中継し、
anthropic-version / anthropic-beta ヘッダをそのまま通す必要があります。
(/v1/models はモデル発見に対応する場合のみで任意)
詳しくは公式ドキュメント参照:環境変数リファレンス、LLM gateway 設定。
組み込みのセキュリティ機能について
エンタープライズ/チーム運用では、だいたい次のような機能が組み込みで使えます。
- managed-settings.json(MDM 配布・OS 保護パス)による設定の強制
- permission ルール / hooks(コマンド実行の許可制・フック)
※ ex.UserPromptSubmithook ならプロンプト入力を検査して送信拒否もできるなど - モデル許可リスト、OpenTelemetryによるテレメトリ送出
前述のとおり hooks は入力(プロンプト)単位の遮断には使えます。
ただし、会話履歴・tool result まで含んだ HTTP リクエストの本文や
応答の SSE ストリームそのものは hooks で介入することはできません。
ここを 1 箇所で・ストリーミング中も検査/遮断/改変するには、
ANTHROPIC_BASE_URL で自作ゲートウェイを用意する必要があります。
MDM でゲートウェイ必須とする
ANTHROPIC_BASE_URL はMDM で配布する OS レベルの
managed-settings.json で固定すれば、ユーザーは変更ができません。
※Claude Code を自前ゲートウェイ経由にすることを強制させることができる
managed スコープは全設定の中で最優先で、env.ANTHROPIC_BASE_URL を入れれば
シェルの export でも上書きできませんが、縛れるのは Claude Code 自体です。
「モデル API 通信を必ず通す」まで保証するなら、ネットワークの egress 制御を併用します。
※ カスタム ANTHROPIC_BASE_URL を使うと、管理コンソールから配信する server-managed settings は利用できません。OS レベルの managed-settings.jsonで配布します。
Create Gateway(WASM・WAC)
ゲートウェイですが、シンプルに「Anthropic Messages API 互換のリバースプロキシを1つ実装する」だけでもOKです。
ただ、ガードレールを増やしたり、グループに応じてルールを変えたりなどをする場合は
ちょっと面倒なので、本記事ではガードレールを WASM コンポーネントで合成してみました。
WASM/WASIとは?
WASM(WebAssembly)
WASMはポータブルなバイナリ命令フォーマットです。
サンドボックス内でほぼネイティブ速度で動作し、特定言語に依存しません。
もとはブラウザ向けですが、現在はサーバでも動きます。
WASI(WebAssembly System Interface)
WASM をブラウザの外で動かすための標準システムインターフェースです。
ファイル・ネットワーク・HTTP などの機能を capability ベース(必要な能力だけを明示的に渡せる設計)で提供します。
Component Model / wac
WASM を型付きインターフェース(WIT)で部品化し、複数のコンポーネントを合成して 1 つにする仕組みです。
wac plug がその合成ツールです。
仕組み
以下のような仕組みで動かします。
- 各ガードレールを1つの WASM コンポーネントにする
- ex.
meter(計測)/secret-scan(秘密検出)/output-mask(応答の伏字化)…
- ex.
- それらを
wac plugで 1 バイナリに合成し、wasmtime serveで起動 - クライアント(Claude Code)は
ANTHROPIC_BASE_URLをここへ向ける
Claude Code ─▶ [meter]─▶[secret-scan]─▶[output-mask]─▶[anthropic-out]─▶ api.anthropic.com
計測 秘密検出 応答を伏字化 宛先固定で中継
◀──────────── ストリーミング(SSE)を貫通して応答が戻る ────────────◀
上図は拡張後の構成例です。後述のTryセクションでは、log → anthropic-out の2つを実装します。
なぜWASM?
WASI 0.3.0で WebAssembly コンポーネントが非同期(ストリーミング)処理を扱えるようになり、
データを少しずつ流す stream と、あとから届く値を表す future が標準で使えるようになりました。
これにより、応答が流れている最中(SSE のチャンク単位)に検査・遮断・差し替えができるようになりました。
たとえば「特定の用語・形式をチェックして、該当したらエラーにする」みたいな処理はそのまま実装できます。
secret-scan:プロンプト(会話履歴)に API キー・秘密鍵・トークンらしき形式が混入していたら、上流に送る前に遮断output-mask:応答ストリーム中のメールアドレスや既知の機密情報を伏字化(ex.hoge@example.com→[redacted:email])
注意: ここで例示する
output-maskは メールアドレスと既知の機密情報が中心で、全てを検出するわけではありません。(画像・base64・バイナリは検査対象外)「何を検出し・何を見ないか」は実装次第なので注意。
WASM 合成で作る利点は次のとおりです。
- 1プロセス・静的合成なので、ストリーミングが途切れない
wasi:httpを出力できるツールチェーンがあれば、どの言語でもOK- データ非保持を設計原則にできる(ゲートウェイ自身が本文・API キーを保存/ログしない。遮断しなかった本文はそのまま Anthropic へ送られる)
コンポーネントを足す、順序を変える、チーム別に構成を変えるなどが合成の設定だけで済むので、
柔軟性が高いです。
Environment
検証は以下の環境で実施。
| 項目 | バージョン |
|---|---|
| OS | macOS 26.4(Apple Silicon / arm64) |
| Claude Code | 2.1.185 |
| Rust | 1.95.0(target wasm32-wasip2) |
| wasmtime | 45.0.1(WASI 0.3 / -S p3 -W component-model-async 対応版) |
| wasm-tools | 1.251.0 |
| wac(wac-cli) | 0.10.0 |
| wit-bindgen | 0.58 |
| Node.js | 20.19.0 |
| mise | 2026.6.0 macos-arm64 |
Setup
デモを動かすために各種インストールしておきましょう。
- Rust + WASM ターゲット(rustup)
% rustup toolchain install 1.95.0
% rustup target add wasm32-wasip2
- wasmtime / wasm-tools
WASI 0.3 の -S p3 -W component-model-async に対応するバージョン。
% mise use -g wasmtime@45.0.1 wasm-tools@1.251.0
- wac(WebAssembly 合成ツール)
% cargo install --locked --version 0.10.0 wac-cli
- wkg(WASI の WIT パッケージ取得ツール。WIT 取得時のみ使用)
% cargo install --locked --version 0.10.0 wkg
- Claude Code
Console 発行の API キー(ANTHROPIC_API_KEY)、サブスクなら claude setup-token
で発行する OAuth トークン(CLAUDE_CODE_OAUTH_TOKEN)を用意しておきます。
Try
以下の最小構成で試してみます。
- ローカルにゲートウェイを立てる
ANTHROPIC_BASE_URLをそこへ向ける- Claude Code を普通に使い、ゲートウェイ経由でいつもどおり動くこと+ログ段の出力を確認
0. 最小Proxy
WASM 合成の話をしましたが、ようするにリバースプロキシです。
以下のようなイメージ。(TypeScriptのサンプル)
import http from "node:http";
const UPSTREAM = "https://api.anthropic.com";
http.createServer(async (req, res) => {
// リクエスト本文をいったん読み取る
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const body = Buffer.concat(chunks).toString("utf8");
// ① 検査:禁止語(ここでは "sk-ant-" で始まる機密情報)が混入していたら遮断
if (/sk-ant-[A-Za-z0-9_\-]{20,}/.test(body)) {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({
type: "error",
error: { type: "invalid_request_error",
message: "Secret detected in the request. Remove it and retry." },
}));
return;
}
/**
* ② 認証ヘッダ(x-api-key / authorization / anthropic-version / anthropic-beta)は
* 上流へ転送するが、host・connection・content-length 等は無条件転送しない(fetch任せ)
*/
const fwd = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
const key = k.toLowerCase();
// host 等の hop-by-hop と accept-encoding(圧縮応答を避ける)は転送しない
if (["host", "connection", "content-length", "transfer-encoding", "accept-encoding"].includes(key)) continue;
if (typeof v === "string") fwd.set(k, v);
}
// GET/HEAD に body を付けると fetch が例外を投げるので分岐する
const method = req.method ?? "GET";
const init: RequestInit = { method, headers: fwd };
if (method !== "GET" && method !== "HEAD") {
init.body = body;
// @ts-ignore Node の fetch でストリーミング応答を受ける
init.duplex = "half";
}
const upstream = await fetch(`${UPSTREAM}${req.url}`, init);
res.writeHead(upstream.status, Object.fromEntries(upstream.headers));
// SSE をチャンクのまま流す(ここで応答を覗いて伏字化することもできる)
for await (const chunk of upstream.body as any) res.write(chunk);
res.end();
// ★ ループバックにバインド
}).listen(8080, "127.0.0.1", () => console.log("gateway on 127.0.0.1:8080"));
これでも「秘密が含まれていたら上流に送らず 400 を返す」ゲートウェイの実装です。
(この最小版は本文を正規表現で見るだけなので回避は容易)
また、応答側のチャンクを書き換えれば、ストリーミング中の伏字化なども可能。
1. サンプルプロジェクトを作成
さきほどの TypeScript は仕組み的な説明でした。
次は WASM コンポーネント2つ ——ログ(自分のロジックを挟む例)と出口(api.anthropic.com へ中継)——
を実際に作り、wac で合成して wasmtime で起動し、実際の Claude Codeで動作確認してみます。
ディレクトリ構成は以下。(WASI 0.3 の WIT パッケージを wit/ に置く)
※デモ実装はRustで行います
gateway-demo/
├── rust-toolchain.toml
├── wit/
│ └── wasi_http@0.3.0-rc-2026-03-15.wasm # WASI 0.3 の WIT
├── log/ # ログコンポーネント(middleware)
│ ├── Cargo.toml
│ └── src/lib.rs
└── anthropic-out/ # 出口コンポーネント:api.anthropic.com へ中継(service)
├── Cargo.toml
└── src/lib.rs
# rust-toolchain.toml
[toolchain]
channel = "1.95.0"
targets = ["wasm32-wasip2"]
2. ログ
リクエストを内側(上流)へ通しつつ、メソッドとパスを stderr に出すだけの middleware です。
ここを検査・集計・遮断などに差し替えれば、自分のガードレールになります。
# log/Cargo.toml
[package]
name = "log"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = { version = "0.58", features = ["async-spawn"] }
[workspace]
// log/src/lib.rs
wit_bindgen::generate!({
path: "../wit/wasi_http@0.3.0-rc-2026-03-15.wasm",
world: "wasi:http/middleware@0.3.0-rc-2026-03-15",
generate_all,
async: true,
});
use exports::wasi::http::handler::Guest;
use wasi::http::handler as inner;
use wasi::http::types::{ErrorCode, Request, Response};
struct Log;
impl Guest for Log {
async fn handle(request: Request) -> Result<Response, ErrorCode> {
let method = request.get_method().await;
let path = request.get_path_with_query().await;
eprintln!("[log] {method:?} {path:?}"); // 自分のロジックを挟む
inner::handle(request).await // 応答はそのまま下流へ
}
}
export!(Log);
3. 出口:api.anthropic.com へ中継
受けたリクエストを api.anthropic.com へ送り、レスポンス(SSE 含む)を
そのまま返す service コンポーネントです。
host(クライアントが付けたゲートウェイ宛ての値)を残したまま転送すると、
接続先の api.anthropic.com と Host が食い違って 403 になるため、
転送してはいけないヘッダだけ除いて宛先を変えます。
(x-api-key / Authorization / anthropic-* はそのまま)
# anthropic-out/Cargo.toml
[package]
name = "anthropic-out"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = { version = "0.58", features = ["async-spawn"] }
[workspace]
// anthropic-out/src/lib.rs
wit_bindgen::generate!({
path: "../wit/wasi_http@0.3.0-rc-2026-03-15.wasm",
world: "wasi:http/service@0.3.0-rc-2026-03-15",
generate_all,
async: true,
});
use exports::wasi::http::handler::Guest;
use wasi::http::client;
use wasi::http::types::{ErrorCode, Fields, Request, Response, Scheme};
struct AnthropicOut;
// 上流へ転送してはいけないヘッダ。
// host を残すと、接続先の Host/authority と食い違って 403 になる
const STRIP: &[&str] = &[
"host", ":authority", "connection", "keep-alive",
"transfer-encoding", "te", "trailer", "upgrade",
"proxy-connection", "accept-encoding",
];
impl Guest for AnthropicOut {
async fn handle(request: Request) -> Result<Response, ErrorCode> {
let method = request.get_method().await;
let path = request.get_path_with_query().await;
// x-api-key / authorization / anthropic-* などはそのまま、host 等だけ除く
let kept: Vec<(String, Vec<u8>)> = request
.get_headers()
.await
.copy_all()
.await
.into_iter()
.filter(|(n, _)| !STRIP.contains(&n.to_ascii_lowercase().as_str()))
.collect();
let headers = Fields::from_list(kept)
.await
.map_err(|e| ErrorCode::InternalError(Some(format!("{e:?}"))))?;
let (res_tx, res_rx) = wit_future::new(|| Ok(()));
let (body, trailers_rx) = Request::consume_body(request, res_rx).await;
let (req, _t) = Request::new(headers, Some(body), trailers_rx, None).await;
let _res_tx = res_tx;
let _ = req.set_method(method).await;
let _ = req.set_path_with_query(path).await;
let _ = req.set_scheme(Some(Scheme::Https)).await;
let _ = req.set_authority(Some("api.anthropic.com".to_string())).await;
client::send(req).await // api.anthropic.com のレスポンスをそのまま返す
}
}
export!(AnthropicOut);
4. ビルド → 合成 → 起動
gateway-demo/ 直下で実行します。
# 1) wasmビルド
cargo build --release --target wasm32-wasip2 --manifest-path log/Cargo.toml
cargo build --release --target wasm32-wasip2 --manifest-path anthropic-out/Cargo.toml
# 2) wac plug で 1 バイナリに合成(log=外側 / anthropic-out=内側=出口)
wac plug log/target/wasm32-wasip2/release/log.wasm \
--plug anthropic-out/target/wasm32-wasip2/release/anthropic_out.wasm \
-o gateway.wasm
# 3) wasmtime で起動
wasmtime serve -S p3,cli -W component-model-async --addr 127.0.0.1:8080 gateway.wasm
5. 実際の Claude Codeで通す
別ターミナルで、ANTHROPIC_BASE_URL をこのゲートウェイに向けて claude を起動します。
MAX などのサブスクは、claude setup-tokenコマンド で OAuth トークンを発行して使います。
※以下はMaxプランの例
# 1) OAuth トークンを発行
% claude setup-token
# → 表示された sk-ant-oat01-... を使用
# 2) ゲートウェイ情報とトークン情報を設定(先に他の認証を外しておく)
% unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN
% export ANTHROPIC_BASE_URL=http://localhost:8080
% export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
# 3) claude 起動
% claude
実行すると、普段どおりにClaude が応答します(ゲートウェイは透過なので使い勝手は変わらない)。
❯ hello claude code.
⏺ Hello! 何かお手伝いできることはありますか?
このとき、ゲートウェイを起動したターミナル側(標準エラー)には log 段の出力が流れ、
リクエストがゲートウェイを通っていることを確認できます。
(/v1/messages/count_tokens と /v1/messages の通信が発生)
[log] Method::Post Some("/v1/messages/count_tokens")
[log] Method::Post Some("/v1/messages?beta=true")
ゲートウェイは認証ヘッダ(Authorization: Bearer …)をそのまま api.anthropic.com へ流します。
※ 開発者 API キーなら export ANTHROPIC_API_KEY=sk-ant-... でもOK
※ ANTHROPIC_API_KEY と CLAUDE_CODE_OAUTH_TOKEN の両方なら API キーが優先。なお ANTHROPIC_AUTH_TOKEN は API キーより優先されます
※優先順位: ANTHROPIC_AUTH_TOKEN > ANTHROPIC_API_KEY > CLAUDE_CODE_OAUTH_TOKEN
6. ゲートウェイのオーバーヘッド測定
プロキシを挟んだことでどのくらいオーバーヘッドがあるか見てみます。
実際の応答時間は Anthropic 側の生成時間に支配されて大きくばらつくため、
ここでは LLM の変動を排除してゲートウェイ自身の処理コストだけを見ます。
上流をローカルのモックLLM(一定ペースで SSE を返すモック)に差し替えて測定してみました。
(a) ゲートウェイ無し(モックLLM に直結)と (b) ゲートウェイ経由で同じリクエストを複数回投げ、
中央値を比較しています。
| 経路 | 初トークン到達(TTFB) 中央値 |
|---|---|
| モックLLM 直結(ゲートウェイ無し) | 0.54 ms |
| ゲートウェイ経由 | 1.07 ms |
| 差分(ゲートウェイの処理コスト) | +約 0.5 ms |
全ストリーム完了まで見ても差分は +約 1.2 msくらいでした(ローカルでの WASM 処理単体の値)。
また、実際の Claude Code(claude -p・MAXプラン・haiku)でゲートウェイ有無の実行時間も測ってみました。
中央値は ゲートウェイなしで 9.82 秒 / ゲートウェイ経由で 9.76 秒(ゲートウェイ経由がわずかに速かった)で、
各回が ばらつきの中(7.2〜14.9 秒)に収まり、今回の測定では明確な差を確認できませんでした。
もう少し本格的にゲートウェイを立てる場合
ローカルで仕組みを簡単に確認したので、もう少し本格的な構成にしてみます。
今回は以下のように、AWS Fargate を使い、実際の Claude Code から 確認しました。
[MDM 配布] ANTHROPIC_BASE_URL = https://gw.internal
│
▼ ← 社内からのみ到達可(内部 ALB / 送信元 IP 制限 / mTLS)
┌──────────────────┐ ┌───────────────────────────────┐ egress 許可は ┌────────────────────┐
│ Claude Code(CLI) │ ───▶ │ ゲートウェイ (Fargate) │ この宛先だけ │ api.anthropic.com │
│ 管理端末 │ │ wasmtime + composed │ ───────────────▶│ │
└──────────────────┘ │ meter→secret-scan→output-mask │ └────────────────────┘
└───────────────────────────────┘
╳ 管理端末 → api.anthropic.com への直行はネットワーク側で遮断(egress 制御)
- ゲートウェイをコンテナ化して常駐(Fargate / Cloud Run など、wasmtime を同梱できる環境)
- ヘルスチェックは上流に出さない(
/healthzをローカルで短絡) - 公開境界を fail-closed に(無制限公開はやめ、内部 ALB か送信元 IP 制限・mTLS 等を必須に)
- 外向き HTTP の宛先はネットワーク側の egress 制御で
api.anthropic.comに限定する(WASI でも段ごとに権限を絞れるが world 設計しだいなので、まずはネットワーク側で担保) - MDM で
ANTHROPIC_BASE_URLを配布+ネットワーク egress 制御で強制
実際に運用について考慮した場合、
(1) egress を本当に塞ぐネットワーク設計
(2) MDM による配布と上書き防止
(3) 監査用のログ設計
(4) チーム別ガードレールの運用
などが関連し、状況や組織の規模、既存ルール等で設計が変わります。
そのあたりを考慮したうえで適切に設計をすれば「Claude Code を組織で安全に使う」ことができそうです。
Summary
Claude Code は ANTHROPIC_BASE_URL で接続先を差し替えられる、
公式のLLM ゲートウェイ機能を持っています。
これを使うと、Claude Code 本体には手を入れずに、自前のゲートウェイで
「送信前に機密を止める」「応答を伏字化する」といった、組み込みにはない内容ベースの制御を、
ストリーミングを保ったまま差し込めます。
本記事では、そのゲートウェイを WASM コンポーネントの合成(WASI 0.3)で作りました。
ガードレールをコンポーネントとして作成すれば、合成の設定だけで差し替え・追加でき、
wasi:http に対応するツールチェーンがあればどの言語でも実装できます。
もっとも、ゲートウェイは Anthropic Messages API 互換の HTTP サービスでありさえすればよく、
WASM 合成はその一例です。
ふつうのリバースプロキシとして実装しても問題なく、動かす場所もクラウド・オンプレミスどちらでも構いません。
組織で使うなら、まず Enterprise プランで managed-settings などの組み込みの統制を効かせ、
そのうえで本記事のようなゲートウェイを組み合わせるのがおすすめです。
MDM で ANTHROPIC_BASE_URL を上書き不可に固定し、ネットワークの egress 制御を併用すれば、
モデル API 通信をゲートウェイ経由に強制できます。
これで、組み込みだけでは届かない、通信内容に応じた処理をカバーできます。
References
- Claude Code: LLM gateway configuration
- Claude Code: Hooks
- Claude Code: Set up Claude Code for your organization
- Claude Code: 環境変数リファレンス
- Claude Code: Settings
- Claude Code: Server-managed settings
- Claude Code: Desktop application
- Claude Code: Authentication
- Claude Code: Legal and compliance
- Anthropic: Handle streaming refusals
- WASI 0.3.0 announcement(Bytecode Alliance)
- Component Model
- wac(WebAssembly Composition)
- Wasmtime






