Claude Codeの通信に自前ゲートウェイを挟む ― WASI 0.3とWACによる実装

Claude Codeの通信に自前ゲートウェイを挟む ― WASI 0.3とWACによる実装

2026.06.23

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_KEYx-api-key ヘッダ)/ サブスクの OAuth トークン CLAUDE_CODE_OAUTH_TOKENAuthorization: Bearer)… 認証
  • ANTHROPIC_CUSTOM_HEADERS … 任意のカスタムヘッダを付与(Name: Value 形式、複数は改行区切り)

注意: ANTHROPIC_BASE_URLapi.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.UserPromptSubmit hook ならプロンプト入力を検査して送信拒否もできるなど
  • モデル許可リスト、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(応答の伏字化)…
  • それらを 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

以下の最小構成で試してみます。

  1. ローカルにゲートウェイを立てる
  2. ANTHROPIC_BASE_URL をそこへ向ける
  3. 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_KEYCLAUDE_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ならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

この記事をシェアする

関連記事