1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【#3】承認・履歴付 OSS SSH MCP server ssh-gate -- 承認フローと SSH 実行編

1
Last updated at Posted at 2026-07-16

「承認キュー → SSH 実行 → 監査ログ」を Go で堅く作る — 承認ゲートウェイの心臓部

連載「AI エージェントに SSH を渡すのが怖いので、人間承認ゲートウェイを自作した」第3回(承認フローと SSH 実行編)。

  • リポジトリ: ssh-getessh-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 を排他区間の外に出す
  • ApproveCommandApproveCommandAndBypassAgent(信頼付与)の 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.RWMutexmu。読み取り中心の操作は 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ステップに分解できます。

  1. idcommand の空チェック。ここで注目すべきは、引数に command を取っていることです。GUI は承認画面でコマンド文字列を編集できる設計なので、承認時に渡された command を正とします(要求時のコマンドと差し替え可能)。「エージェントが書いたコマンドを人間が直してから実行する」という運用を、API レベルで素直に許しているわけです。
  2. findRequest(id) で要求、findConnection(req.Host) で接続先を引く(どちらも RLock 下のルックアップ)。
  3. executeSSH(conn, command) で実際に SSH 実行。ここが時間のかかる処理で、後述のとおりロックの外で動きます。
  4. 結果に応じて 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クリックに対応させています。
  • 失敗は早期に返す。ポリシー保存に失敗したら実行しないし、実行に失敗してもメッセージはそのまま返す。状態がちぐはぐにならないようにしています。

付与されたポリシーは setAgentApprovalBypassapp.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つの仕事が混ざっています。

  1. 状態の読み書き(メモリ上の requests / conns)— 速い。排他が要る。
  2. SSH 実行executeSSH)— 遅い(ネットワーク I/O、最大数十秒)。
  3. 永続化(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 を直接触らないので、データ競合が起きません。

setConnectionStatusapp.go:714)も同じ型です。ロック内で接続先の Status/LastChecked を書き換え、更新後のコピーを取り、ロック外で store.saveConnection する。「ロック内で更新、ロック外で永続化」を全メソッドで貫くことで、単一の粗いロックでも実用上の引っかかりを避けています。

4.3 要求をキューに積む経路

ついでに、要求がキューに入る側も見ておきます。承認キューへの登録は queueCommandrecordCommandRequestapp.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)                                // ロック外で永続化
}

newCommandRequestapp.go:644)が ID を採番します。

// app.go:644
seq := requestIDSeq.Add(1)
ID: fmt.Sprintf("REQ-%d-%d", now.UnixNano()/int64(time.Millisecond), seq),

ここの <seq> は地味ですが重要です。プロセス内で単調増加する atomic.Uint64app.go:24requestIDSeq)で、以前は REQ-<millis> のミリ秒精度だけでした。承認省略エージェントが並列・連続でコマンドを投げると同一ミリ秒で ID が衝突し、SQLite の upsert(ON CONFLICT(id) DO UPDATE)が古い行を上書きして履歴が消える不具合がありました。seq を足して同一ミリ秒でも一意にすることで解消しています(回帰テスト TestRapidCommandRequestsPersistDistinctly)。監査ログは消えてはいけない——この修正自体が承認ゲートウェイの思想を象徴しています。

承認省略エージェント向けの「即時実行+記録」経路が executeCommandAndLogapp.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 競争

コマンド実行の本体が runSSHCommandssh_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:7696)。

  • session.Run(command)別 goroutine で走らせ、結果をバッファ付き channel done に流す。
  • メインは select で「ctx.Done()(タイムアウト/キャンセル)」と「done(実行完了)」を競争させる。
  • タイムアウトが勝ったら、session.Signal(ssh.SIGKILL)リモート側のプロセスを確実に殺しsession.Close() してから ctx.Err() を返す。途中まで取れた出力(stdout.String())も返します。
  • donebuffered(容量1)なのも重要です。タイムアウト側が勝って誰も done を受信しなくても、実行 goroutine は送信でブロックせずに終了でき、goroutine リークしません。

context のタイムアウトは executeSSH 側で CommandTimeout(既定30秒、app.go:690)から作られます。**「放置したコマンドは必ず止まる」**を、タイムアウトと SIGKILL の両輪で保証しているわけです。

5.3 認証メソッドの優先順位

接続確立は openSSHClientssh_exec.go:99)が担い、認証の組み立ては sshAuthMethodsssh_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
}

整理すると:

  1. 認証方式が「パスワード保管」なら、ssh.Password 一本で返す(資格情報が空ならエラー)。
  2. それ以外(鍵認証)は、まず ssh-agent の鍵を候補に加えるagentAuthMethods)。
  3. 鍵ファイル(既定 ~/.ssh/id_ed25519expandPath~ 展開)を読む。読めなくても agent があればそれだけで続行
  4. 鍵のパースに失敗し、かつパスフレーズがあれば ParsePrivateKeyWithPassphrase で再試行。
  5. 最終的に、成功した公開鍵認証を先頭に、ssh-agent 認証を後続に並べて返す(append([]ssh.AuthMethod{ssh.PublicKeys(signer)}, auth...))。

つまり鍵認証の優先順位は「指定鍵ファイル → ssh-agent」。鍵ファイルが本命で、agent はフォールバック兼追加候補という位置づけです。ssh-agent は agentAuthMethodsssh_exec.go:173)が dialSSHAgent()(OS 依存。Unix ソケット / Windows 名前付きパイプ。詳細は第7回)で繋がったときだけ有効になります。

5.4 正直コーナー:ホスト鍵検証が既定オフ

ここが本記事で一番正直に書いておきたいところです。ホスト鍵の検証は hostKeyCallbackssh_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 一発で厳格モードに切り替わります。ホスト鍵不一致は explainSSHErrorapp.go:801)が「known_hosts のホスト鍵が一致しません…」と日本語で案内します。

「便利さのために安全側の既定を緩めた箇所がある。ただしそれを設定で締められる逃げ道は必ず残す」——これがこのアプリのスタンスです。隠さず書いておきます。


6. 多層の安全装置 — 1個ではなく層で積む

第1回〜第3回で出てきた仕掛けを、安全装置として並べ直すとこうなります(解説.md 14.1 ベース)。どれか1個に頼っていないのが要点です。

  1. デフォルト承認必須:MCP 経由のコマンドはリスクに関係なく承認キュー行き。AutoExecuteLowRisk はバックエンド(sanitizeMCPSettingsapp.go:1058)とフロント(collectMCPForm)の両方で false に固定され、UI から有効化できない
  2. エージェント名の必須化agent_name(または requested_by)が無い要求は拒否。誰の要求かを必ず記録する。
  3. 明示的な信頼付与:承認省略は、人間が GUI で特定エージェントに対し意図的に付与したときだけ有効(第3節)。未申告エージェント には付与不可。
  4. リスク可視化classifyRisk が High/Medium/Low を付け、人間の判断を助ける。ただしこれはヒューリスティックな文字列マッチで、実行可否を決める権限は持たせていない(判断補助に留める)。
  5. 出力上限cappedBuffer で stdout/stderr を MaxOutputKB に制限(第5.1節)。
  6. タイムアウトと kill:接続・コマンドそれぞれにタイムアウトがあり、超過時は SIGKILL(第5.2節)。
  7. 監査ログ:全要求が 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 保存 接続先設定の一部。毎回入力させると実用に堪えない

パスフレーズの扱いは testConnectionConfigapp.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 でも normalizeConnectionapp.go:807)が connection.KeyPassphrase = "" でクリアするので、保存パスにパスフレーズが混入しません。実行時は executeSSH(第4.1節)がメモリから読み出して conn.KeyPassphrase に詰めるだけ。接続先を削除すれば delete(a.sshPassphrases, name)app.go:413)でメモリからも消えます。

「最も機微なものほど、永続化しない」。トークンは利便性のために DB に置きますが(その代わり GUI には maskToken でマスク表示し、平文は返さない)、パスフレーズはプロセスのライフサイクルと運命を共にさせる。機微度に応じて保持の強度を変えるという線引きです。


まとめ — 安全装置は1個ではなく層で積む

  • 承認1件のデータフローは「要求と接続先を引く → ロック外で SSH 実行 → ロック内で更新・ロック外で永続化」という1本道。ApproveCommandapp.go:496)がその入口。
  • 状態は単一 RWMutex で守るが、長時間の SSH を排他区間から追い出すexecuteSSHapp.go:689)。「ロック内で更新、ロック外で永続化」を全メソッドで貫く(updateRequestResult / setConnectionStatus)。
  • SSH 実行レイヤは状態に依存せず、cappedBuffer で出力上限select でタイムアウトと実行完了を競争させ、超過時は SIGKILLssh_exec.go:7696)。認証は「指定鍵 → 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:32App 構造体(単一 mu、状態の唯一の所有者)
  • app.go:78CommandRequest(承認キュー兼監査ログのレコード)
  • app.go:496ApproveCommand(承認して実行する入口)
  • app.go:521ApproveCommandAndBypassAgent(信頼付与の API)
  • app.go:603 / app.go:627setAgentApprovalBypass / agentApprovalBypassEnabled(承認省略ポリシー)
  • app.go:638 / app.go:661queueCommand / recordCommandRequest(キュー登録)
  • app.go:644newCommandRequestREQ-<millis>-<seq> 採番、requestIDSeqapp.go:24
  • app.go:668executeCommandAndLog(承認省略エージェントの即時実行)
  • app.go:689executeSSH(実行の最終ゲート、ロック解放)
  • app.go:714setConnectionStatus(ロック内更新・ロック外永続化)
  • app.go:746updateRequestResult(結果の書き戻し)
  • app.go:314testConnectionConfig(パスフレーズのメモリ保持)
  • app.go:1058sanitizeMCPSettingsAutoExecuteLowRisk=false 固定)
  • ssh_exec.go:21cappedBuffer(出力上限)
  • ssh_exec.go:56runSSHCommand(一発実行と select 競争、7696
  • ssh_exec.go:99openSSHClient(接続確立)
  • ssh_exec.go:135sshAuthMethods(認証メソッドの優先順位)
  • ssh_exec.go:181hostKeyCallback(既定オフの InsecureIgnoreHostKey

逐行の詳細は docs/解説.md 第6章(app.go)・第8章(ssh_exec.go)・第14章(セキュリティ)・付録A/C を参照。

この記事はオープンソース ssh-gate の紹介記事です。


関連記事


図1.png

クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?