
目次
こんにちは、マサシです。
フリーランスでWeb制作をしています(スタジオについて)。
最近、AIエージェント(Claude Codeとか)に外部サービスの操作を任せる場面が増えてきました。
で、そのたびに引っかかるのが「実APIキーを、エージェントが読める場所に置いていいのか?」という不安なんですよね。
環境変数に本物のトークンを入れて、エージェントに「あとはよろしく」と渡す。
便利なんですが、そのキーはエージェントのコンテキストにもログにも残りうるわけで、なんとなく気持ち悪い。
そんなときに、OneCLI という「エージェントに実キーを見せないための箱」を見つけたので、自分のPCで実際に立てて動かしてみました。
先に結論
・OneCLIは、エージェントとサービスの間に立つOSSのゲートウェイ(Apache-2.0)。エージェントは FAKE_KEY という偽物を送り、本物のキーはゲートウェイが送信の瞬間だけ差し込む
・Dockerで15分くらいで自分の環境に立った。ログイン不要のローカルモードがある
・「エージェントはFAKE_KEYを送ったのに、宛先には本物のキーが届いていた」を、宛先側のレスポンスとゲートウェイのログの両方で確認できた
・ただしREADMEだけだと詰まる箇所が3つあった(認証は Bearer じゃなく Basic・APIパスは /api/secrets・HTTPSはMITMなのでCA証明書を信頼させる必要がある)
同じ不安を持っている人が、試す前に3つの罠を避けられるように書きます。
なお今回は本物のGitHubやGmailのキーは一切使わず、検証用のダミーキーと、ヘッダーをそのまま返してくれる httpbin.org だけで確かめています。
OneCLIって何をするものか
ひとことで言うと「秘密の金庫+すり替え係」です。
本物のキーはOneCLIの中にAES-256-GCMで暗号化して保管しておく。
エージェントには FAKE_KEY みたいな偽の値だけ渡す。
エージェントがOneCLIのゲートウェイ経由でHTTPリクエストを送ると、ゲートウェイが「この宛先ならこのキー」と判断して、送信の直前に本物へすり替える。
エージェント自身は、本物のキーを一度も触らない。
構成は、Rust製のゲートウェイ(ポート10255)と、Next.js製のダッシュボード(ポート10254)と、PostgreSQLの3つです。
立てるところ(ここは素直に動く)
環境はWSL2(Ubuntu)+Docker。
リポジトリを持ってきて、Composeで起動するだけでした。
git clone https://github.com/onecli/onecli.git
cd onecli
docker compose -f docker/docker-compose.yml up -d --wait
イメージは ghcr.io から降ってくるビルド済みのものなので、Rustのコンパイル待ちもありません。
--wait を付けておくと、PostgreSQLとアプリが「healthy」になるまで待ってくれます。
起動したら http://localhost:10254 がダッシュボードです。
ここまでは拍子抜けするくらい素直でした。

ダッシュボードには、GitHub・Gmail・Google Drive など、つなぎ込める先が最初からずらっと並んでいます。


ログインとかアカウント作成は要らないんですか?

ローカルで使うぶんには要らないよ。何も設定しなければ「単一ユーザーのローカルモード」で起動して、そのまま使える。チームで複数人が使うときだけ Google OAuth を有効にする感じだね。
本題:FAKE_KEYが本物にすり替わるのを見る
ここからが確かめたかったところです。
まず、ダミーの「本物キー」をOneCLIに登録します。
宛先は httpbin.org、Authorization ヘッダーに Bearer 実キー の形で差し込む設定にしました。
curl -X POST http://127.0.0.1:10254/api/secrets \
-H "Content-Type: application/json" \
-d '{
"name": "httpbin-demo",
"type": "generic",
"value": "sk-REALSECRET-...(検証用ダミー)",
"hostPattern": "httpbin.org",
"injectionConfig": { "headerName": "Authorization", "valueFormat": "Bearer {value}" }
}'
そして、エージェントの立場でゲートウェイ経由のリクエストを送ります。
エージェントが送るのは Bearer FAKE_KEY です。
本物のキーは知りません。
httpbin.org は「受け取ったヘッダーをそのまま返す」ので、宛先に何が届いたかが丸わかりになります。
curl -x http://127.0.0.1:10255 \
--proxy-user "agent:<エージェントのアクセストークン>" \
--cacert ca.pem \
-H "Authorization: Bearer FAKE_KEY" \
https://httpbin.org/headers
返ってきた宛先の受信ヘッダーがこれです。
"Authorization": "Bearer sk-REALSECRET-...(検証用ダミー)"
送ったのは FAKE_KEY なのに、httpbin側には本物(今回はダミー)が届いていました。
ゲートウェイのログにも、はっきり出ます。
CONNECT mode="mitm" agent="Default Agent"
MITM method=GET url=https://httpbin.org/headers status=200 injections_applied=1
injections_applied=1。
すり替えが1回起きた、という記録です。
エージェントのコンテキストにもコマンド履歴にも、残るのは FAKE_KEY だけ。
これが見たかった絵でした。
ハマりどころ その1:認証は Bearer じゃなく Basic(30分溶かした)
最初、僕はゲートウェイへの認証を Proxy-Authorization: Bearer <トークン> で送っていました。
そうしたら、すり替えが起きない。
httpbinには Bearer FAKE_KEY がそのまま届く。
ログを見たら理由がわかりました。
CONNECT mode="tunnel" agent="-"
agent="-"。
エージェントとして認識されていないんです。
OneCLIのゲートウェイは、Proxy認証を Basic(トークンをパスワード側に入れる、GitHubなんかと同じ流儀)で読みます。
Bearer で送ると認証ヘッダーとして拾われず、「素性のわからない相手」として、ただの素通しトンネル(mode="tunnel")になる。
素通しなので、FAKE_KEY がそのまま本物のサービスに飛んでいく。
漏洩ではないんですが(本物は金庫の中で、飛ぶのは偽物)、「なぜ効かないんだ」でしばらく溶かしました。
curlなら --proxy-user "agent:<トークン>" の形にするだけで直ります。
切り分けのコツは、ログの mode= を見ること。
tunnel なら認証が拾えていない、mitm なら認証が通ってすり替えの土俵に乗っている、の一発判定です。

`tunnel` のときって、危なくないんですか?勝手に素通ししちゃうなら。

飛ぶのは偽物のキーだから、相手側で「認証エラー」になるだけなんだ。本物は漏れない。ただ「なんで動かないの?」が起きやすいから、まず `mode=` を見る、と覚えておくと早いよ。
ハマりどころ その2:APIのパスは /api/secrets
シークレットをAPIから登録するとき、リポジトリのフロント側コードには /v1/secrets という書き方が出てきます。
それを真似て /api/v1/secrets に投げたら、404 でした。
実際に受け付けてくれたのは /api/secrets(v1 なし)です。
/api/v1/secrets は404、/api/secrets は201。
ソースの見た目と、動いているサーバーのパスが一致しないことがある、という小さな罠でした。
ハマりどころ その3:HTTPSはMITMなので、CA証明書を信頼させる
OneCLIのゲートウェイは、HTTPS通信を一度開いて(中身を見て)キーを差し込んで、また閉じる、という中間者(MITM)方式です。
なので、クライアント側がゲートウェイの証明書を信頼していないと、そもそもTLSの接続で弾かれます。
証明書はコンテナの中の /app/data/gateway/ca.pem にあります。
docker cp onecli:/app/data/gateway/ca.pem ./ca.pem
curlならこれを --cacert ca.pem で渡す。
Node.jsなら NODE_EXTRA_CA_CERTS、システム全体なら SSL_CERT_FILE に食わせる、という手当てが要ります。
「エージェントを動かす環境ごとに、このCAを信頼させる一手間が必要」というのは、導入前に知っておくと気が楽です。
権限を分ける(ここが本命だと思う)
キーを隠せるだけじゃなくて、「どのエージェントが、どのキーを使えるか」を分けられるのが、たぶん一番おいしいところです。
さっきの Default Agent とは別に、権限を絞った Limited Agent を作って、同じ httpbin.org へのリクエストを送ってみました。
こちらには httpbin のキーを渡していません。
結果、ログはこうなりました。
agent="Default Agent" ... injections_applied=1
agent="Limited Agent" ... injections_applied=0
同じ宛先でも、権限のないエージェントは injections_applied=0。
本物のキーは差し込まれず、FAKE_KEY のまま外に出ます。
「このエージェントには読み取り用のキーだけ」「あのエージェントには触らせない」を、エージェント側のコードを変えずに、金庫側で決められる。
複数のエージェントを並行で動かしたい人には、これが効くと思います。
正直な限界と、誰に向くか
いいことばかりでもないので、触ってみて感じた前提も書いておきます。
・MITM方式なので、エージェントを動かす環境それぞれにCA証明書を信頼させる手間がかかる。ここを面倒に感じるかは人による
・今回試したのはダミーキー+httpbinまで。本物のGitHubやGmailを相手にするなら、各サービスのホスト設定と権限設計をもう一段ちゃんとやる必要がある
・ローカル単一ユーザーモードは手軽だけど、チームで使うならGoogle OAuthの設定が要る
逆に、こういう人にはハマると思います。
・Claude Codeなどのエージェントに、複数の外部サービスを触らせたい
・キーを環境変数やドットファイルにばらまくのが気持ち悪い
・エージェントごとに「触れる範囲」を分けて事故の範囲を小さくしたい
まとめ
「エージェントに本物のキーを渡さない」という考え方自体は前からあったんですが、FAKE_KEY が送信の瞬間に本物へすり替わるところを、宛先のレスポンスとログの両方で自分の目で見られたのが収穫でした。
詰まったのは、認証が Basic だったところ(Bearerだと無言で素通し)、APIパスが /api/secrets だったところ、HTTPSのCA証明書を信頼させるところ、の3つ。
ここさえ避ければ、Dockerで15分くらいで自分の環境に立ちます。
同じ「エージェントに実キーを渡すの怖いな」を持っている人は、ダミーキーとhttpbinで一度動かしてみると、腹落ちが早いと思います。

