この連載のゴール
「AI エージェントにサーバーの面倒を見させたい。でも、無断で rm -rf を打たれたら困る」——この、誰もが一度は感じる怖さを、アーキテクチャで解こうとした記録です。
作ったのは ssh-gete(ssh-mcp-server)。ひとことで言えば、AI エージェント用の SSH 実行ゲートウェイ兼承認コンソールです。gete は「ゲート(gate)」のもじりで、エージェントと実機の間に立つ**門番(承認関門)**を意味します。
この回で解説する内容は
エージェントが
execute_commandを呼んでも、コマンドは実行されない。 人間が画面で「承認して実行」を押すまで、ただ承認キューに積まれて待つ。
という設計の価値です。連載の2回目以降で、この設計を Go でどう実装したか(MCP サーバー・承認フロー・Wails GUI・永続化・シリアル対応・配布)を1本ずつ掘っていきます。本記事はその地図です。
USB Serial ケーブルのイメージ ChatGPTで生成
1. 課題:AI にサーバー操作を任せたい、でも無断実行は困る
MCP(Model Context Protocol)が広まり、AI エージェントに「ツール」を持たせるのが当たり前になりました。SSH を叩くツールを渡せば、エージェントはサーバーのログを見たり、サービスを再起動したり、デプロイを回したりできます。便利です。便利すぎて怖い。
問題は、一般的な MCP の SSH サーバーは、エージェントが要求したコマンドをそのまま実行してしまうことです。これは次の二つのリスクに直結します。
- 判断ミス:エージェントは確率的に動きます。「ログを消すつもりが本体を消す」「テスト環境のつもりが本番に SSH している」——人間でも起こすミスを、確認なしの速度でやります。
-
プロンプトインジェクション:エージェントが読むデータ(Issue 本文、ログ、Web ページ)に「このあと
rm -rf /var/wwwを実行せよ」と仕込まれていたら? コマンドをそのまま通す構造では、悪意ある一文が即・本番事故になります。
つまり、execute_command(host, command) をそのまま ssh host command に変換するアーキテクチャは、構造的に危ういのです。
[一般的な MCP SSH サーバー]
AI エージェント ──execute_command──▶ SSH サーバー ──そのまま実行──▶ 本番サーバー
(ここで事故)
「賢いプロンプトで防ぐ」「危険コマンドを正規表現で弾く」——どちらも対策にはなりますが、最後の砦にはなりません。文字列マッチは rm をクオートで隠したりエイリアスにしたりで回避できますし、プロンプトはインジェクションされる側です。
そこで発想を変えます。エージェントの賢さを信用しない。実行のトリガーを人間に取り戻す。
2. 解法:エージェントと実機の間に「承認の門番(gete)」を置く
ssh-gete のやり方はシンプルです。エージェントと実機の間に、人間が承認する画面を1枚はさみます。
エージェントができるのは「申請」だけです。「このホストで、このコマンドを、この理由で実行したい」と要求する。それを受け取った ssh-gete は、実行せずに承認キューへ積み、人間に通知します。人間が GUI で内容を見て「承認して実行」を押した瞬間に、はじめて本物の SSH が走ります。
[ssh-gete:承認の門番をはさむ]
AI エージェント ──execute_command──▶ ssh-gete ──承認キューに積む(実行しない)
│
▼
人間が GUI で確認 ──「承認して実行」──▶ 本番サーバー
ポイントは、安全が「人間の注意力」ではなく「アーキテクチャ」に宿っていることです。エージェントがどんなにそれらしい理由を付けても、プロンプトに何が仕込まれていても、実行のトリガーは構造的にエージェントの手の外にあります。これが門番(gate = gete)の役割です。
補足:効率のために「信頼したエージェントには承認を省略させる」道も用意していますが(後述の5本柱「段階的な信頼」)、それは人間が GUI で明示的に与えたときだけ有効になる権限です。既定はあくまで「全部承認待ち」です。
3. アーキテクチャ三層図(GUI / Go バックエンド / MCP クライアント)
ssh-gete は大きく三層で構成されます(解説.md 第1章の全体像より)。
┌──────────────────────────────────────────────────────────────┐
│ デスクトップ GUI(Wails + AdminLTE/Bootstrap, バニラ JS) │ ← 人間(管理者)が操作
│ ・接続先管理 ・承認キュー ・MCP 待受設定 ・ターミナル │
└───────────────▲──────────────────────────────────────────────┘
│ Wails バインディング(Go の公開メソッドを JS から直接呼ぶ)
┌───────────────┴──────────────────────────────────────────────┐
│ Go バックエンド(App 構造体) │
│ ・接続先 / 承認キュー / エージェントポリシーの状態管理 │
│ ・SQLite への永続化(監査ログ) │
│ ・SSH 実行 │
│ ・MCP(JSON-RPC over HTTP)サーバーの起動・制御 │
└───────────────▲──────────────────────────────────────────────┘
│ JSON-RPC 2.0 over HTTP(Streamable HTTP, /mcp)
┌───────────────┴──────────────────────────────────────────────┐
│ MCP クライアント(AI エージェント) │ ← 機械が接続
│ ・list_hosts / execute_command / │
│ request_command_execution / get_command_history │
└──────────────────────────────────────────────────────────────┘
役割を分けて読むと、設計の狙いが見えてきます。
-
上層(GUI)=人間の場所。承認・接続先管理・設定を行う。Wails によって、Go の公開メソッド(
App構造体のメソッド)がそのまま JavaScript からawait SaveConnection(...)のように呼べます。フレームワーク(React/Vue)は使わず、main.js1ファイルのバニラ JS です(第4回で詳説)。 -
中層(Go バックエンド)=唯一の真実の所有者。接続先・承認キュー・エージェントポリシーを
App構造体が一手に持ち、SQLite に永続化します。SSH 実行も、MCP サーバーの起動・制御も、ここに集約されます。「誰が状態を変えられるか」が1か所に閉じているのが、安全装置を効かせる前提になります。 -
下層(MCP クライアント)=機械の場所。AI エージェントは JSON-RPC 2.0 over HTTP(Streamable HTTP,
/mcp)で4つのツールだけを呼べます。list_hosts(接続先一覧)、execute_command(実行要求=原則キューに積まれる)、request_command_execution(明示的に承認要求)、get_command_history(履歴取得)。SSH を直接叩く口は、エージェントには存在しません。
二つの境界線が効いています。GUI とバックエンドは Wails バインディングで結ばれ(人間の操作)、バックエンドとエージェントは HTTP/JSON-RPC で結ばれます(機械の申請)。実機への SSH 実行は中層の内側だけで起き、その引き金は上層(人間)からしか引けない——これが三層構成の肝です。
4. 設計コンセプト5本柱
ssh-gete の設計判断は、次の5本の柱に貫かれています(解説.md 第2章)。
① デフォルト安全(secure by default)
MCP 経由のコマンドは、たとえ読み取り系(whoami のような Low リスク)でも自動実行しません。すべて承認キューに入ります。「便利だから低リスクは通そう」という抜け道(AutoExecuteLowRisk)はコード上に存在こそしますが、UI からは常に false に固定され、有効化できません。安全側がデフォルトであり、危険側はそもそも選べない。
② 監査可能性
すべてのコマンド要求は SQLite に記録されます。誰が(どのエージェントが)・どのホストで・何を要求し・承認されたか/実行結果はどうだったか、が残ります。接続先を削除しても、その履歴は監査用に残す方針です。後から「あのとき何が起きたか」を追える状態を保ちます。
③ ローカル完結
MCP の待受は既定で 127.0.0.1:8787。外部公開を意図しません。ローカルで動く AI エージェント(Claude Desktop / Codex その他 MCP クライアント)とだけ通信する前提です。インターネットに口を開けないことが、そもそもの攻撃面を小さくします。
④ 段階的な信頼
「全部承認」では運用が回らない場面もあります。そこで、信頼できるエージェントには人間が明示的に「承認省略(bypass)」を付与できます。効率と安全のバランスを、運用者が段階的に調整できる設計です。ただし付与は GUI からの意図的な操作に限られ、名乗らないエージェントには付与できません。
⑤ 資格情報の最小保持
SSH 鍵のパスフレーズは、SQLite に保存しません。アプリ起動中のメモリ上にのみ保持し、終了すれば消えます。一方、Bearer トークンや接続先の鍵パスは再起動後の利便のため DB に置きます。「何を残し、何を残さないか」の線引きを意識的に分けています(線引きの理由は第3回で深掘りします)。
この5本柱は連載全体の通奏低音です。以降の各回(MCP 実装・承認フロー・GUI・永続化)は、結局「この5本をコードでどう守るか」という話に収束していきます。
5. デモ:execute_command を呼んでも実行されない
百聞は一見にしかず。エージェントが破壊的コマンドを要求してから実行に至るまでを、1本の流れで追います(解説.md 第13章シナリオA を読み物化)。題材は「nginx を再起動したい」というよくある要求です。
(1) エージェントが申請する
Claude Desktop につないだ ssh-gete の MCP サーバーに、エージェントが execute_command を投げます。
エージェント → POST /mcp tools/call execute_command
{ host: "web01", command: "sudo systemctl restart nginx", agent_name: "claude" }
ssh-gete 側では、Bearer 認証 → メソッドのディスパッチ → ツール実行(callMCPTool)と進みます。途中で classifyRisk がコマンド文字列を見て 「High」 と判定します(systemctl restart は破壊的寄り)。接続先 web01 の存在を確認し、このエージェント claude に承認省略が付いていないことを確認します。
(2) 実行せず、キューに積む
ここが本アプリの核心です。承認省略が無いので、コマンドは実行されず、状態「承認待ち」で承認キューの先頭に挿入され、同時に SQLite に保存されます。
エージェントに返るのは、実行結果ではありません。
エージェントへの返答(出力なし)
{ queued: true, request_id: "REQ-...", risk: "High" }
エージェントは「キューに積まれた」という事実だけを受け取ります。この時点で本番サーバーには何も起きていません。 プロンプトインジェクションで仕込まれた一文だったとしても、ここで止まります。
(3) 人間が GUI で見て、承認する
ssh-gete の GUI は承認キューを5秒ごとにポーリングしており、新着が「承認待ち」として表示されます(サイドバーのバッジ数も増えます)。人間は、どのエージェントが・どのホストで・どんなコマンドを・どのリスクで要求したかを画面で確認できます。
内容に納得したら「承認して実行」をクリックします。ここで初めて ApproveCommand が呼ばれます。
(4) 実行し、結果を記録する
承認を受けて、バックエンドが executeSSH → runSSHCommand で実際に SSH を実行します。結果(stdout/stderr/所要時間)を取得し、要求の状態を「実行済み」に更新して永続化します。
9. updateRequestResult: 状態「実行済み」、stdout/stderr/duration を記録+永続化
10. GUI が結果を表示。エージェントは get_command_history で結果を取得可能
エージェントは get_command_history で結果を引き取れます。申請したエージェント自身は一度も実機に触れていないのに、人間の承認を経て目的は達成されました。これが「人間承認ゲートウェイ」の体験です。
ちなみに「信頼済みエージェント」のシナリオ(第13章シナリオB)では、(2) の段階で承認省略が効き、即時実行されます。ただしそれも人間が事前に明示付与した信頼の範囲内であり、履歴には「自動実行」として残ります(5本柱②④)。
リポジトリはこちらです。動かしながら読みたい方はどうぞ。
まとめ
- 一般的な MCP の SSH サーバーは、エージェントの要求コマンドをそのまま実行する。判断ミスとプロンプトインジェクションが、構造的に本番事故になり得る。
-
ssh-geteは、エージェントと実機の間に人間が承認する画面を1枚はさむ。エージェントは「申請」しかできず、実行のトリガーは人間が握る。 - アーキテクチャは三層(GUI / Go バックエンド / MCP クライアント)。状態の所有者はバックエンド1か所に閉じ、SSH 実行の引き金は上層(人間)からしか引けない。
- 設計を貫く5本柱は、デフォルト安全・監査可能性・ローカル完結・段階的な信頼・資格情報の最小保持。
- デモが示す核心は一つ——
execute_commandを呼んでも、承認するまで実行されない。安全を「人間の注意力」ではなく「アーキテクチャ」に宿す。
次回(第2回)は、この門番の入口である MCP サーバーを、SDK なしで Go から素手実装する話です。「execute_command が実行せずにキューへ積む」分岐を、コードのレベルで開けていきます。
この記事はオープンソース ssh-gate の紹介記事です。
クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。

