「承認キュー → SSH 実行 → 監査ログ」を Go で堅く作る — 承認ゲートウェイの心臓部
連載「AI エージェントに SSH を渡すのが怖いので、人間承認ゲートウェイを自作した」第3回(承認フローと SSH 実行編)。
- リポジトリ:
ssh-gete(ssh-gate-oss) — https://github.com/yoshiyakato/ssh-gate-oss- 対象バージョン:
main(タグ未発行。本記事のコード引用は記事公開時点のmainを参照)- この回は単体でも読めます。連載の入口は第1回(コンセプト編)です。
0. この記事のゴール
ssh-gete は「AI エージェントが要求した SSH コマンドを、人間が承認してから実行する」デスクトップアプリです。第2回(MCP 実装編)では、エージェントが execute_command を呼んでも実行されず承認キューに積まれるだけ、というところまでを見ました。
第3回の主役は、その続き——承認ボタンが押された瞬間から SSH が完了するまでです。ここがアプリの心臓部で、「便利さ」と「事故らなさ」が最後にぶつかる場所でもあります。
この記事のゴールは次の1本のデータフローを、コードを追いながら完全に理解することです。
GUI で「承認して実行」を押す
→ 要求と接続先を引く
→ ロック外で SSH 実行(タイムアウト・出力上限つき)
→ 結果を「ロック内で更新、ロック外で永続化」
→ 監査ログとして SQLite に残る
そのうえで、設計判断として次を扱います。
- 状態を単一 RWMutex で守りつつ、長時間の SSH を排他区間の外に出す
-
ApproveCommandとApproveCommandAndBypassAgent(信頼付与)の API 設計 - SSH 実行レイヤの内部:認証メソッドの優先順位、
cappedBufferの出力上限、タイムアウトと SIGKILL のselect競争 - 多層の安全装置を「1個ではなく層で」積む話
- 資格情報の保持方針(パスフレーズはメモリのみ、トークンは DB)
- 正直コーナー:ホスト鍵検証が既定オフな理由
1. App 構造体 — 状態の唯一の所有者
まず舞台を確認します。app.go で最も重要なのは App 構造体です(app.go:32)。このアプリのすべての状態がここに集約されます。
// app.go:32
type App struct {
ctx context.Context
mu sync.RWMutex // 全フィールドを保護する単一ロック
store *Store // SQLite
dbPath string
legacyConnections string // 旧 JSON パス(移行用)
legacyMCPSettings string
conns []Connection // 接続先
requests []CommandRequest // コマンド要求・履歴(新しい順)
mcp MCPSettings // MCP 設定
mcpToken string // Bearer トークン(平文・メモリ)
mcpAgents map[string]time.Time // 接続してきたエージェントの最終観測時刻
agentPolicies map[string]AgentPolicy// エージェント別の承認省略設定
sshPassphrases map[string]string // 接続先名→パスフレーズ(メモリのみ)
termMu sync.Mutex // ターミナルセッション専用ロック
term *terminalSession // 稼働中の対話端末(第6回)
}
設計上の要点は3つです。
-
状態は1か所に集める。Wails 経由で JS から呼ばれる公開メソッドは、すべてこの
Appのメソッドです。状態の所有者が散らばらないので、「誰がいつ何を書き換えたか」を追いやすい。 -
守るのは単一の
sync.RWMutex(mu)。読み取り中心の操作はRLock、変更はLock。粒度は粗いですが、デスクトップ単一ユーザーの規模ではこれで十分に単純で安全です。 -
term/termMuだけはmuから独立。第6回で足す対話ターミナルの状態は別ロックで守ります。長時間の送受信が承認キューや接続先のアクセスをブロックしないためです(この「長時間処理をロック外に出す」という考え方は、後述のexecuteSSHでも一貫しています)。
要求の本体はこの型です(app.go:78)。
// app.go:78
type CommandRequest struct {
ID string // "REQ-<UnixMilli>-<seq>"
Host string
User string
Command string
Reason string
RequestedBy string // 要求元エージェント名
RequestedAt string
Risk string // High / Medium / Low
Status string // 承認待ち / 実行済み / 実行失敗 / 拒否 / 自動実行済み ...
Stdout string
Stderr string
Duration string
}
requests は 新しい順のスライスで、要求が来るたびに先頭へ挿入されます(後述の recordCommandRequest)。これがそのまま承認キューであり、実行後は監査ログにもなります。同じレコードが状態遷移していくだけ、という素朴さがポイントです。
2. データフローの起点 — ApproveCommand
人間が GUI で「承認して実行」を押すと呼ばれるのが ApproveCommand です(app.go:496)。これが心臓部の入口です。
// app.go:496
func (a *App) ApproveCommand(id string, command string) ActionResult {
id = strings.TrimSpace(id)
command = strings.TrimSpace(command)
if id == "" || command == "" {
return ActionResult{OK: false, Message: "承認対象のコマンドが不足しています"}
}
req, ok := a.findRequest(id)
if !ok {
return ActionResult{OK: false, Message: "承認対象のリクエストが見つかりません"}
}
conn, ok := a.findConnection(req.Host)
if !ok {
return ActionResult{OK: false, Message: "対象ホストが接続先一覧にありません"}
}
result, err := a.executeSSH(conn, command)
if err != nil {
a.updateRequestResult(id, command, "実行失敗", result, err)
return ActionResult{OK: false, Message: fmt.Sprintf("%s の実行に失敗しました: %s", id, explainSSHError(conn, err))}
}
a.updateRequestResult(id, command, "実行済み", result, nil)
return ActionResult{OK: true, Message: fmt.Sprintf("%s を承認し、実行しました", id)}
}
流れは4ステップに分解できます。
-
idとcommandの空チェック。ここで注目すべきは、引数にcommandを取っていることです。GUI は承認画面でコマンド文字列を編集できる設計なので、承認時に渡されたcommandを正とします(要求時のコマンドと差し替え可能)。「エージェントが書いたコマンドを人間が直してから実行する」という運用を、API レベルで素直に許しているわけです。 -
findRequest(id)で要求、findConnection(req.Host)で接続先を引く(どちらもRLock下のルックアップ)。 -
executeSSH(conn, command)で実際に SSH 実行。ここが時間のかかる処理で、後述のとおりロックの外で動きます。 - 結果に応じて
updateRequestResultで状態を「実行済み」または「実行失敗」に更新。stdout/stderr/所要時間も同時に記録します。失敗時はユーザー向けにexplainSSHErrorでメッセージを整形します。
エージェントから見える挙動を改めて整理すると、第2回で見たとおり「キューに積まれた(queued: true)」というレスポンスしか返りません。出力はエージェントには戻らず、人間が ApproveCommand を起動して初めて SSH が走る——この一線が承認ゲートウェイの本質です。
3. 信頼付与の API — ApproveCommandAndBypassAgent
承認のたびにボタンを押すのは、信頼できるエージェントには煩雑です。そこで「このエージェントは今後承認なしで実行してよい」という運用ボタンが ApproveCommandAndBypassAgent です(app.go:521)。
// app.go:521
func (a *App) ApproveCommandAndBypassAgent(id string, command string) ActionResult {
id = strings.TrimSpace(id)
req, ok := a.findRequest(id)
if !ok {
return ActionResult{OK: false, Message: "承認対象のリクエストが見つかりません"}
}
agentName := strings.TrimSpace(req.RequestedBy)
if agentName == "" || agentName == "未申告エージェント" {
return ActionResult{OK: false, Message: "承認省略にはエージェント名が必要です"}
}
if result := a.setAgentApprovalBypass(agentName, true); !result.OK {
return result
}
result := a.ApproveCommand(id, command)
if !result.OK {
return result
}
return ActionResult{OK: true, Message: fmt.Sprintf("%s を承認して実行し、%s の承認省略を許可しました", id, agentName)}
}
API 設計としてのポイントは3つです。
-
エージェント名がないと信頼を付けられない。
RequestedByが空、または未申告エージェントの場合は拒否します。「誰を信頼するか」が特定できない相手に白紙委任しない、という安全装置です。 -
ポリシー付与と実行を1操作にまとめている。
setAgentApprovalBypass(agentName, true)でポリシーを立て、続けて通常のApproveCommandを呼ぶ。「信頼する」と「今回の1件を実行する」を別々のボタンにせず、人間の「承認して、今後は任せる」という意思を1クリックに対応させています。 - 失敗は早期に返す。ポリシー保存に失敗したら実行しないし、実行に失敗してもメッセージはそのまま返す。状態がちぐはぐにならないようにしています。
付与されたポリシーは setAgentApprovalBypass(app.go:603)で SQLite に保存され、メモリの agentPolicies に反映されます。
// app.go:603
func (a *App) setAgentApprovalBypass(agentName string, enabled bool) ActionResult {
// ... AgentPolicy{AgentName, ApprovalBypass, UpdatedAt} を作り
// store に保存してから a.agentPolicies[agentName] に反映 ...
}
そして MCP ツール側がこれを参照するのが agentApprovalBypassEnabled です(app.go:627)。
// app.go:627
func (a *App) agentApprovalBypassEnabled(agentName string) bool {
agentName = strings.TrimSpace(agentName)
if agentName == "" {
return false
}
a.mu.RLock()
defer a.mu.RUnlock()
policy, ok := a.agentPolicies[agentName]
return ok && policy.ApprovalBypass
}
第2回で触れた execute_command / request_command_execution の分岐は、ここが true を返したときだけ「承認キューを飛ばして即実行」になります。承認省略は強い権限です。一度付与すれば、そのエージェントは以後ノーチェックで実行できます。だからこそ「人間が GUI で明示的に、名前のあるエージェントにだけ」付与できる設計に縛っています。
4. ロック設計 — 「ロック内で更新、ロック外で永続化」
ここからが Go の並行設計の本題です。承認1件の処理には、性質の違う3つの仕事が混ざっています。
-
状態の読み書き(メモリ上の
requests/conns)— 速い。排他が要る。 -
SSH 実行(
executeSSH)— 遅い(ネットワーク I/O、最大数十秒)。 - 永続化(SQLite への書き込み)— そこそこ遅い。I/O を伴う。
単一 RWMutex で全状態を守る素朴な設計のまま、これらをナイーブに Lock で囲むと、遅い SSH 実行の間ずっと他の操作(GUI のポーリング、別エージェントの要求)がブロックされます。これを避けるのが、本アプリのロック設計の肝です。
4.1 長時間の SSH をロック区間から追い出す
ApproveCommand(第2節)をもう一度見ると、executeSSH の呼び出しはロックを取っていません。ロックを取るのは中の executeSSH です(app.go:689)。
// app.go:689
func (a *App) executeSSH(conn Connection, command string) (commandResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeoutSeconds(conn.CommandTimeout, 30))
defer cancel()
a.mu.RLock()
outputCap := a.mcp.MaxOutputKB * 1024
strictHostKey := a.mcp.StrictHostKey
conn.KeyPassphrase = a.sshPassphrases[conn.Name]
a.mu.RUnlock() // ← ここで即解放
if outputCap <= 0 {
outputCap = defaultOutputCap // 128 KiB
}
return runSSHCommand(ctx, conn, command, outputCap, strictHostKey) // ← ロック外
}
RLock は「設定値(出力上限・厳格ホスト鍵・パスフレーズ)をメモリから読み出す一瞬」だけです。読み終わったら RUnlock し、実際の SSH(runSSHCommand)はロックの外で走ります。だから30秒かかる重いコマンドの最中でも、GUI は接続先一覧を読めるし、別の要求もキューに積めます。
conn は値渡しなので、ロック外で KeyPassphrase を書き換えても共有状態を汚しません。設定スナップショットをローカル変数に取り出してロックを手放す、という典型パターンです。
4.2 結果は「ロック内で更新、ロック外で永続化」
実行結果を書き戻す updateRequestResult は、この原則を最もきれいに体現しています(app.go:746)。
// app.go:746
func (a *App) updateRequestResult(id string, command string, status string, result commandResult, execErr error) {
a.mu.Lock()
var updated CommandRequest
found := false
for i := range a.requests {
if a.requests[i].ID != id {
continue
}
a.requests[i].Command = command
a.requests[i].Status = status
a.requests[i].Stdout = result.Stdout
a.requests[i].Stderr = result.Stderr
if execErr != nil {
if a.requests[i].Stderr != "" {
a.requests[i].Stderr += "\n"
}
a.requests[i].Stderr += execErr.Error()
}
if result.Duration > 0 {
a.requests[i].Duration = result.Duration.Round(100 * time.Millisecond).String()
}
updated = a.requests[i]
found = true
break
}
a.mu.Unlock() // ← メモリ更新まではロック内
if found {
a.persistCommandRequest(updated) // ← SQLite 書き込みはロック外
}
}
-
Lock区間でやるのはメモリのrequestsを書き換えることだけ。スライスを走査して該当行を見つけ、状態・出力・所要時間を更新し、更新後の値をupdatedにコピーする。ここは速い。 -
ロックを手放してから
persistCommandRequestで SQLite に書く。DB の I/O 待ちで排他区間を長引かせない。 - 永続化に渡すのは**ロック内で取ったコピー(
updated)**です。ロック外でa.requestsを直接触らないので、データ競合が起きません。
setConnectionStatus(app.go:714)も同じ型です。ロック内で接続先の Status/LastChecked を書き換え、更新後のコピーを取り、ロック外で store.saveConnection する。「ロック内で更新、ロック外で永続化」を全メソッドで貫くことで、単一の粗いロックでも実用上の引っかかりを避けています。
4.3 要求をキューに積む経路
ついでに、要求がキューに入る側も見ておきます。承認キューへの登録は queueCommand → recordCommandRequest(app.go:638, app.go:661)です。
// app.go:638
func (a *App) queueCommand(host, command, reason, requestedBy, risk string) CommandRequest {
req := newCommandRequest(host, command, reason, requestedBy, risk, "承認待ち")
a.recordCommandRequest(req)
return req
}
// app.go:661
func (a *App) recordCommandRequest(req CommandRequest) {
a.mu.Lock()
a.requests = append([]CommandRequest{req}, a.requests...) // 先頭に挿入(新しい順)
a.mu.Unlock()
a.persistCommandRequest(req) // ロック外で永続化
}
newCommandRequest(app.go:644)が ID を採番します。
// app.go:644
seq := requestIDSeq.Add(1)
ID: fmt.Sprintf("REQ-%d-%d", now.UnixNano()/int64(time.Millisecond), seq),
ここの <seq> は地味ですが重要です。プロセス内で単調増加する atomic.Uint64(app.go:24 の requestIDSeq)で、以前は REQ-<millis> のミリ秒精度だけでした。承認省略エージェントが並列・連続でコマンドを投げると同一ミリ秒で ID が衝突し、SQLite の upsert(ON CONFLICT(id) DO UPDATE)が古い行を上書きして履歴が消える不具合がありました。seq を足して同一ミリ秒でも一意にすることで解消しています(回帰テスト TestRapidCommandRequestsPersistDistinctly)。監査ログは消えてはいけない——この修正自体が承認ゲートウェイの思想を象徴しています。
承認省略エージェント向けの「即時実行+記録」経路が executeCommandAndLog(app.go:668)です。状態を「自動実行中」で作って executeSSH し、結果に応じて「自動実行済み」/「自動実行失敗」に更新して記録します。承認を経た ApproveCommand と、状態ラベルが違うだけで構造はそっくりです。
5. SSH 実行レイヤ — ssh_exec.go
executeSSH が委譲する先が ssh_exec.go です。ここは golang.org/x/crypto/ssh を使った純粋な SSH 層で、App(状態)に一切依存しません。引数で受け取った Connection だけで動きます。状態を持つ層と、I/O を行う層をきれいに分けているわけです。
5.1 出力上限 — cappedBuffer
巨大な出力でメモリを食い潰さないための仕掛けが cappedBuffer です(ssh_exec.go:21)。
// ssh_exec.go:21
type cappedBuffer struct {
buf bytes.Buffer
max int
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
if b.max <= 0 {
return len(p), nil // 上限0以下なら全捨て(呼び出し側には成功を返す)
}
remaining := b.max - b.buf.Len()
if remaining > 0 {
if len(p) > remaining {
_, _ = b.buf.Write(p[:remaining]) // 上限ぶんだけ書く
} else {
_, _ = b.buf.Write(p)
}
}
return len(p), nil // 捨てた分も「書けた」ことにする
}
func (b *cappedBuffer) String() string {
if b.buf.Len() >= b.max {
return b.buf.String() + "\n[ssh-gete: output truncated]"
}
return b.buf.String()
}
ミソは Write が常に len(p) を返すことです。上限を超えた分は捨てますが、呼び出し元(session.Run)にはエラーを返しません。出力が大きすぎてもコマンド実行自体は最後まで進み、バッファだけが頭打ちになる。String() は上限に達していたら末尾に [ssh-gete: output truncated] を付け、人間に「ここで切れている」と知らせます。stdout/stderr 双方にこれを使います。
5.2 一発実行とタイムアウトの select 競争
コマンド実行の本体が runSSHCommand(ssh_exec.go:56)です。第6回(シリアル編)の「終わりのない対話ストリーム」と対照的に、こちらは 「1コマンド投げる → 実行 → stdout/stderr と終了 → 終わり」 という単発モデルです。承認キューが成立するのは、この「コマンド→終了」という単位があるからです。
// ssh_exec.go:56
func runSSHCommand(ctx context.Context, conn Connection, command string, outputCap int, strictHostKey bool) (commandResult, error) {
start := time.Now()
client, err := openSSHClient(ctx, conn, strictHostKey)
// ... defer client.Close() ...
session, err := client.NewSession()
// ... defer session.Close() ...
stdout := &cappedBuffer{max: outputCap}
stderr := &cappedBuffer{max: outputCap}
session.Stdout = stdout
session.Stderr = stderr
session.Stdin = io.Reader(strings.NewReader("")) // stdin は空(対話なし)
done := make(chan error, 1)
go func() {
done <- session.Run(command) // ← 別 goroutine で実行
}()
select {
case <-ctx.Done(): // タイムアウト/キャンセルが先
_ = session.Signal(ssh.SIGKILL) // リモートプロセスを kill
_ = session.Close()
return commandResult{
Stdout: stdout.String(), Stderr: stderr.String(), Duration: time.Since(start),
}, ctx.Err()
case err := <-done: // 実行完了が先
return commandResult{
Stdout: stdout.String(), Stderr: stderr.String(), Duration: time.Since(start),
}, err
}
}
ここの設計のキモが select の競争です(ssh_exec.go:76〜96)。
-
session.Run(command)は別 goroutine で走らせ、結果をバッファ付き channeldoneに流す。 - メインは
selectで「ctx.Done()(タイムアウト/キャンセル)」と「done(実行完了)」を競争させる。 - タイムアウトが勝ったら、
session.Signal(ssh.SIGKILL)でリモート側のプロセスを確実に殺し、session.Close()してからctx.Err()を返す。途中まで取れた出力(stdout.String())も返します。 -
doneがbuffered(容量1)なのも重要です。タイムアウト側が勝って誰もdoneを受信しなくても、実行 goroutine は送信でブロックせずに終了でき、goroutine リークしません。
context のタイムアウトは executeSSH 側で CommandTimeout(既定30秒、app.go:690)から作られます。**「放置したコマンドは必ず止まる」**を、タイムアウトと SIGKILL の両輪で保証しているわけです。
5.3 認証メソッドの優先順位
接続確立は openSSHClient(ssh_exec.go:99)が担い、認証の組み立ては sshAuthMethods(ssh_exec.go:135)です。ここに優先順位の設計が出ます。
// ssh_exec.go:135
func sshAuthMethods(conn Connection) ([]ssh.AuthMethod, error) {
if strings.Contains(conn.AuthMethod, "パスワード") {
if conn.Credential == "" {
return nil, errors.New("password credential is empty")
}
return []ssh.AuthMethod{ssh.Password(conn.Credential)}, nil
}
auth := agentAuthMethods() // ssh-agent の鍵
keyPath := conn.Credential
if keyPath == "" {
keyPath = "~/.ssh/id_ed25519" // 既定鍵
}
keyPath = expandPath(keyPath)
key, err := os.ReadFile(keyPath)
if err != nil {
if len(auth) > 0 {
return auth, nil // 鍵ファイルが無くても agent があれば続行
}
return nil, fmt.Errorf("read ssh key %s: %w", keyPath, err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
if conn.KeyPassphrase != "" {
signer, passphraseErr := ssh.ParsePrivateKeyWithPassphrase(key, []byte(conn.KeyPassphrase))
if passphraseErr == nil {
return append([]ssh.AuthMethod{ssh.PublicKeys(signer)}, auth...), nil
}
return nil, fmt.Errorf("parse ssh key %s with passphrase: %w", keyPath, passphraseErr)
}
return nil, fmt.Errorf("parse ssh key %s: %w", keyPath, err)
}
return append([]ssh.AuthMethod{ssh.PublicKeys(signer)}, auth...), nil
}
整理すると:
-
認証方式が「パスワード保管」なら、
ssh.Password一本で返す(資格情報が空ならエラー)。 - それ以外(鍵認証)は、まず ssh-agent の鍵を候補に加える(
agentAuthMethods)。 - 鍵ファイル(既定
~/.ssh/id_ed25519、expandPathで~展開)を読む。読めなくても agent があればそれだけで続行。 - 鍵のパースに失敗し、かつパスフレーズがあれば
ParsePrivateKeyWithPassphraseで再試行。 - 最終的に、成功した公開鍵認証を先頭に、ssh-agent 認証を後続に並べて返す(
append([]ssh.AuthMethod{ssh.PublicKeys(signer)}, auth...))。
つまり鍵認証の優先順位は「指定鍵ファイル → ssh-agent」。鍵ファイルが本命で、agent はフォールバック兼追加候補という位置づけです。ssh-agent は agentAuthMethods(ssh_exec.go:173)が dialSSHAgent()(OS 依存。Unix ソケット / Windows 名前付きパイプ。詳細は第7回)で繋がったときだけ有効になります。
5.4 正直コーナー:ホスト鍵検証が既定オフ
ここが本記事で一番正直に書いておきたいところです。ホスト鍵の検証は hostKeyCallback(ssh_exec.go:181)が決めますが、既定ではホスト鍵を検証しません。
// ssh_exec.go:181
func hostKeyCallback(strict bool) (ssh.HostKeyCallback, error) {
if !strict && os.Getenv("SSH_GETE_STRICT_HOSTKEY") != "1" {
return ssh.InsecureIgnoreHostKey(), nil // ← 既定はこっち
}
knownHostsPath := expandPath("~/.ssh/known_hosts")
if _, err := os.Stat(knownHostsPath); err == nil {
return knownhosts.New(knownHostsPath)
}
return nil, fmt.Errorf("known_hosts not found: %s", knownHostsPath)
}
StrictHostKey(MCP 設定)が true、または環境変数 SSH_GETE_STRICT_HOSTKEY=1 のときだけ ~/.ssh/known_hosts を読み、knownhosts.New で本物の検証コールバックを作ります。それ以外は ssh.InsecureIgnoreHostKey()——どんなホスト鍵でも受け入れます。
なぜこんな割り切りをしているか、正直に書きます。
-
建前ではなく実害:これは中間者攻撃(MITM)に対して無防備な状態です。攻撃者が経路上にいれば、偽のサーバーに気づかず接続し得ます。本番運用では
StrictHostKeyを有効にすべき箇所です。 -
割り切った理由:
ssh-geteはローカル単一ユーザーのデスクトップアプリで、「手元の検証用ホストにサクッと繋ぎたい」場面が多い。known_hostsを毎回整備させると、初見ホストへの接続がそのたびに失敗し、known_hosts not foundでつまずく。開発・検証時の摩擦を下げるために、既定は緩く、本番は環境変数/設定で締めるという選択をしました。 -
逃げ道は用意してある:
SSH_GETE_STRICT_HOSTKEY=1一発で厳格モードに切り替わります。ホスト鍵不一致はexplainSSHError(app.go:801)が「known_hosts のホスト鍵が一致しません…」と日本語で案内します。
「便利さのために安全側の既定を緩めた箇所がある。ただしそれを設定で締められる逃げ道は必ず残す」——これがこのアプリのスタンスです。隠さず書いておきます。
6. 多層の安全装置 — 1個ではなく層で積む
第1回〜第3回で出てきた仕掛けを、安全装置として並べ直すとこうなります(解説.md 14.1 ベース)。どれか1個に頼っていないのが要点です。
-
デフォルト承認必須:MCP 経由のコマンドはリスクに関係なく承認キュー行き。
AutoExecuteLowRiskはバックエンド(sanitizeMCPSettings、app.go:1058)とフロント(collectMCPForm)の両方でfalseに固定され、UI から有効化できない。 -
エージェント名の必須化:
agent_name(またはrequested_by)が無い要求は拒否。誰の要求かを必ず記録する。 -
明示的な信頼付与:承認省略は、人間が GUI で特定エージェントに対し意図的に付与したときだけ有効(第3節)。
未申告エージェントには付与不可。 -
リスク可視化:
classifyRiskが High/Medium/Low を付け、人間の判断を助ける。ただしこれはヒューリスティックな文字列マッチで、実行可否を決める権限は持たせていない(判断補助に留める)。 -
出力上限:
cappedBufferで stdout/stderr をMaxOutputKBに制限(第5.1節)。 - タイムアウトと kill:接続・コマンドそれぞれにタイムアウトがあり、超過時は SIGKILL(第5.2節)。
-
監査ログ:全要求が SQLite に残り、接続先を消しても履歴は保持(
DeleteConnectionのメッセージ「コマンド履歴は監査用に残しています」、app.go:416)。/audit/eventsで外部からも取得可能。
ネットワーク面でも、既定待受は 127.0.0.1(ローカルのみ)、Bearer 認証は任意でトークンは GUI にマスク表示、ボディサイズ上限と HTTP タイムアウトを設定、と層を重ねています。
持ち帰り:便利機能を「実装した上で UI から無効化固定する」
特に強調したいのが項目1の AutoExecuteLowRisk です。これは「Low リスクのコマンドは自動実行してよい」という便利フラグで、フィールド自体は実装されています。にもかかわらず、sanitizeMCPSettings が呼ばれるたびに問答無用で false に戻され、UI からも true にできません。
// app.go:1058(sanitizeMCPSettings の末尾)
settings.AutoExecuteLowRisk = false
「実装しない」のではなく「実装した上で、有効化経路を塞ぐ」。一見矛盾していますが、これは意図的です。
- 将来「やはり Low は自動実行したい」となったときの土台は残す。
- ただし現時点では、リスク分類がヒューリスティックである以上、それに実行可否を委ねるのは危険(
rmをクオートで隠す、エイリアスで回避する、等で簡単に騙せる)。だから既定で塞ぐ。 - フロントとバックの二重で
false固定するので、片方をいじっても破れない。
「危ない便利機能は、消すのではなく無効化を固定する」。これが安全装置を層で積むという思想の、いちばん分かりやすい現れです。
7. 資格情報の保持方針 — どこに何を置くか
最後に、秘密情報の置き場所の線引きです(解説.md 14.3)。App 構造体(第1節)を見ると、3種類の資格情報が登場します。
| 資格情報 | 保持場所 | 理由 |
|---|---|---|
SSH 鍵パスフレーズ(sshPassphrases) |
メモリのみ(DB 非保存) | 最も機微。プロセスが死ねば消えるべき値。再起動後に再入力させる |
Bearer トークン(mcpToken) |
DB 保存 | 再起動後も MCP の Bearer 認証を維持する必要がある |
| 接続先の credential(鍵パス/パスワード) | DB 保存 | 接続先設定の一部。毎回入力させると実用に堪えない |
パスフレーズの扱いは testConnectionConfig(app.go:314)に表れています。
// app.go:314(抜粋)
if strings.TrimSpace(passphrase) != "" {
conn.KeyPassphrase = passphrase
} else {
a.mu.RLock()
conn.KeyPassphrase = a.sshPassphrases[conn.Name] // メモリから引く
a.mu.RUnlock()
}
// ... 疎通テストが成功したら ...
if strings.TrimSpace(passphrase) != "" {
a.mu.Lock()
a.sshPassphrases[conn.Name] = passphrase // メモリに覚える(DB には書かない)
a.mu.Unlock()
}
疎通テストが成功して初めて、入力されたパスフレーズをメモリの sshPassphrases に覚えます。SQLite には一切書きません。SaveConnection でも normalizeConnection(app.go:807)が connection.KeyPassphrase = "" でクリアするので、保存パスにパスフレーズが混入しません。実行時は executeSSH(第4.1節)がメモリから読み出して conn.KeyPassphrase に詰めるだけ。接続先を削除すれば delete(a.sshPassphrases, name)(app.go:413)でメモリからも消えます。
「最も機微なものほど、永続化しない」。トークンは利便性のために DB に置きますが(その代わり GUI には maskToken でマスク表示し、平文は返さない)、パスフレーズはプロセスのライフサイクルと運命を共にさせる。機微度に応じて保持の強度を変えるという線引きです。
まとめ — 安全装置は1個ではなく層で積む
- 承認1件のデータフローは「要求と接続先を引く → ロック外で SSH 実行 → ロック内で更新・ロック外で永続化」という1本道。
ApproveCommand(app.go:496)がその入口。 - 状態は単一 RWMutex で守るが、長時間の SSH を排他区間から追い出す(
executeSSH、app.go:689)。「ロック内で更新、ロック外で永続化」を全メソッドで貫く(updateRequestResult/setConnectionStatus)。 - SSH 実行レイヤは状態に依存せず、
cappedBufferで出力上限、selectでタイムアウトと実行完了を競争させ、超過時は SIGKILL(ssh_exec.go:76〜96)。認証は「指定鍵 → ssh-agent」の優先順位。 - 信頼付与(
ApproveCommandAndBypassAgent)は強い権限なので、「人間が GUI で、名前のあるエージェントにだけ」付与できるよう API で縛る。 -
安全装置は層で積む:デフォルト承認必須・エージェント名必須・明示的信頼付与・リスク可視化・出力上限・タイムアウト/kill・監査ログ。便利機能
AutoExecuteLowRiskは「実装した上で UI からfalse固定」という割り切り。 - 資格情報は機微度で保持を変える:パスフレーズはメモリのみ、トークンは DB。
-
正直コーナー:ホスト鍵検証は既定オフ(
InsecureIgnoreHostKey)。ローカル前提の摩擦軽減のための割り切りで、SSH_GETE_STRICT_HOSTKEY=1で締められる逃げ道は用意してある。
承認ゲートウェイの価値は、派手な1つの仕組みではなく、互いに独立した小さな安全装置を何層も重ねたところにあります。1枚破られても次がある。そして「危ない便利機能は消すのではなく無効化を固定する」という選択が、その思想を一番よく表しています。
次回(第4回)は、この心臓部を操作する管理画面を、Electron でも Tauri でもなく Wails + バニラ JS 1ファイルでどう作ったかを扱います。
参照コード(本記事の引用元)
-
app.go:32—App構造体(単一mu、状態の唯一の所有者) -
app.go:78—CommandRequest(承認キュー兼監査ログのレコード) -
app.go:496—ApproveCommand(承認して実行する入口) -
app.go:521—ApproveCommandAndBypassAgent(信頼付与の API) -
app.go:603/app.go:627—setAgentApprovalBypass/agentApprovalBypassEnabled(承認省略ポリシー) -
app.go:638/app.go:661—queueCommand/recordCommandRequest(キュー登録) -
app.go:644—newCommandRequest(REQ-<millis>-<seq>採番、requestIDSeqはapp.go:24) -
app.go:668—executeCommandAndLog(承認省略エージェントの即時実行) -
app.go:689—executeSSH(実行の最終ゲート、ロック解放) -
app.go:714—setConnectionStatus(ロック内更新・ロック外永続化) -
app.go:746—updateRequestResult(結果の書き戻し) -
app.go:314—testConnectionConfig(パスフレーズのメモリ保持) -
app.go:1058—sanitizeMCPSettings(AutoExecuteLowRisk=false固定) -
ssh_exec.go:21—cappedBuffer(出力上限) -
ssh_exec.go:56—runSSHCommand(一発実行とselect競争、76〜96) -
ssh_exec.go:99—openSSHClient(接続確立) -
ssh_exec.go:135—sshAuthMethods(認証メソッドの優先順位) -
ssh_exec.go:181—hostKeyCallback(既定オフのInsecureIgnoreHostKey)
逐行の詳細は
docs/解説.md第6章(app.go)・第8章(ssh_exec.go)・第14章(セキュリティ)・付録A/C を参照。
この記事はオープンソース ssh-gate の紹介記事です。
関連記事
クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。
